Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

177 lines
5.5 KiB
TypeScript

import type {
AgentMessageContentVM,
AgentThinkingContentVM
} from "../contracts/agentMessageRowVM";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import type { AgentComputedToolGroupVM } from "./agentToolGroupingProjection";
import type { AgentTurnSequenceItemVM } from "./agentTurnSequenceProjection";
import {
projectAgentSingleToolRow,
projectAgentToolGroupRowFromGroup
} from "./agentToolGroupingProjection";
import { chunkBy } from "./chunkBy";
/**
* A render unit is one item that will become (part of) a single transcript
* row. Unlike the raw turn sequence, tool groups have already been resolved to
* one unit spanning their calls, and grouped/suppressed indices have been
* dropped. This flattening is what lets the row pass reason about immediate
* neighbours instead of looking ahead across skipped indices.
*/
export type AgentRenderUnit =
| { tag: "tool-group"; group: AgentComputedToolGroupVM }
| { tag: "tool"; call: AgentToolCallVM }
| {
tag: "user-message";
row: Extract<AgentTranscriptRowVM, { kind: "message" }>;
}
| { tag: "thinking"; thinking: AgentThinkingContentVM }
| { tag: "message"; message: AgentMessageContentVM };
export type AgentRenderUnitTag = AgentRenderUnit["tag"];
/** Pure classifier for a raw turn sequence item. */
export function tagOf(
item: AgentTurnSequenceItemVM
): "user-message" | "message" | "thinking" | "tool" {
switch (item.kind) {
case "user-message":
return "user-message";
case "assistant-message":
return "message";
case "thinking":
return "thinking";
case "tool-call":
return "tool";
}
}
/**
* Run predicate: a thinking unit is absorbed into the assistant message that
* immediately follows it (rendered as that message's `thinking`). Every other
* adjacency starts a new run, so tools, tool groups and lone thinking each
* become their own row.
*/
export function thinkingAbsorbedByMessage(
previous: AgentRenderUnit,
current: AgentRenderUnit
): boolean {
return previous.tag === "thinking" && current.tag === "message";
}
/**
* Resolve the raw turn sequence into render units: emit one unit per tool
* group (skipping its span), drop grouped/suppressed indices, and tag the
* remaining message/thinking/tool items. Removing skipped items here is what
* makes a thinking and a later message become adjacent units when only
* suppressed tools sat between them.
*/
export function toRenderUnits(
sequence: readonly AgentTurnSequenceItemVM[],
groups: ReadonlyMap<number, AgentComputedToolGroupVM>,
skippedIndices: ReadonlySet<number>
): AgentRenderUnit[] {
const units: AgentRenderUnit[] = [];
for (let index = 0; index < sequence.length; index += 1) {
const group = groups.get(index);
if (group) {
units.push({ tag: "tool-group", group });
index = group.endIndex;
continue;
}
if (skippedIndices.has(index)) {
continue;
}
const item = sequence[index];
if (!item) {
continue;
}
switch (item.kind) {
case "user-message":
units.push({ tag: "user-message", row: item.row });
break;
case "assistant-message":
units.push({ tag: "message", message: item.message });
break;
case "thinking":
units.push({ tag: "thinking", thinking: item.thinking });
break;
case "tool-call":
units.push({ tag: "tool", call: item.call });
break;
}
}
return units;
}
/**
* Render one run into a transcript row. A run is either a singleton
* (tool group / tool / lone thinking) or leading thinking units followed by a
* single assistant message that absorbs them.
*/
export function renderRun(
run: readonly AgentRenderUnit[],
turnId: string,
agentSessionId?: string
): AgentTranscriptRowVM {
const last = run.at(-1);
if (!last) {
throw new Error("renderRun received an empty run");
}
if (last.tag === "tool-group") {
return projectAgentToolGroupRowFromGroup(
turnId,
last.group,
agentSessionId
);
}
if (last.tag === "tool") {
return projectAgentSingleToolRow(last.call, turnId);
}
if (last.tag === "user-message") {
return last.row;
}
const thinking = run
.filter(
(unit): unit is Extract<AgentRenderUnit, { tag: "thinking" }> =>
unit.tag === "thinking"
)
.map((unit) => unit.thinking);
if (last.tag === "message") {
return {
kind: "message",
id: `message:assistant:${last.message.id}`,
turnId: last.message.turnId,
speaker: "assistant",
messages: [last.message],
thinking,
occurredAtUnixMs: last.message.occurredAtUnixMs
};
}
return {
kind: "message",
id: `message:thinking:${turnId}:${thinking.map((entry) => entry.id).join("+")}`,
turnId,
speaker: "assistant",
messages: [],
thinking,
occurredAtUnixMs:
thinking.at(-1)?.occurredAtUnixMs ?? thinking[0]?.occurredAtUnixMs ?? null
};
}
/** Project a turn's sequence (with computed groups/skips) into transcript rows. */
export function projectTurnRows(
sequence: readonly AgentTurnSequenceItemVM[],
groups: ReadonlyMap<number, AgentComputedToolGroupVM>,
skippedIndices: ReadonlySet<number>,
turnId: string,
agentSessionId?: string
): AgentTranscriptRowVM[] {
const units = toRenderUnits(sequence, groups, skippedIndices);
return chunkBy(units, thinkingAbsorbedByMessage).map((run) =>
renderRun(run, turnId, agentSessionId)
);
}