源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
import type { AgentActivityTurn } from "@tutti-os/agent-activity-core";
|
||||
import type { AgentGUIObservationGap } from "../../../types";
|
||||
import type {
|
||||
AgentMessageContentVM,
|
||||
AgentMessageRowVM
|
||||
} from "../contracts/agentMessageRowVM";
|
||||
import type { AgentTranscriptTurnGroup } from "./agentTranscriptModel";
|
||||
|
||||
export type AgentTurnTiming =
|
||||
| {
|
||||
kind: "live";
|
||||
startedAtUnixMs: number;
|
||||
frozenAtUnixMs?: number | null;
|
||||
observationGapPresentationState?: NonNullable<
|
||||
AgentGUIObservationGap["presentationState"]
|
||||
>;
|
||||
}
|
||||
| { kind: "settled"; elapsedSeconds: number };
|
||||
|
||||
export type AgentTurnDuration =
|
||||
| { kind: "seconds"; seconds: number }
|
||||
| { kind: "minutes"; minutes: number }
|
||||
| { kind: "minutes-seconds"; minutes: number; seconds: number };
|
||||
|
||||
export type AgentTurnWorkSectionRow =
|
||||
AgentTranscriptTurnGroup["rows"][number] & {
|
||||
renderKey?: string;
|
||||
};
|
||||
|
||||
export interface AgentTurnWorkSectionSegment {
|
||||
kind: "visible" | "work";
|
||||
rows: AgentTurnWorkSectionRow[];
|
||||
}
|
||||
|
||||
export interface AgentTurnWorkSectionModel {
|
||||
timing: AgentTurnTiming;
|
||||
leadingRows: AgentTurnWorkSectionRow[];
|
||||
sections: AgentTurnWorkSectionSegment[];
|
||||
collapseEligible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects at most one participant header per speaker in each presentation
|
||||
* turn. Completed collapsed turns prefer a visible message so the Agent header
|
||||
* does not disappear into the hidden work section.
|
||||
*/
|
||||
export function findParticipantHeaderRenderKeys(
|
||||
groups: readonly AgentTranscriptTurnGroup[],
|
||||
rowKeys: readonly string[],
|
||||
modelByGroupKey: ReadonlyMap<string, AgentTurnWorkSectionModel | null>,
|
||||
participantTurnIndexByRowIndex: ReadonlyMap<number, number>
|
||||
): ReadonlySet<string> {
|
||||
const headerCandidateByTurn = new Map<
|
||||
number,
|
||||
Map<
|
||||
AgentMessageRowVM["speaker"],
|
||||
{ renderKey: string; visibilityPriority: number }
|
||||
>
|
||||
>();
|
||||
|
||||
for (const group of groups) {
|
||||
const model = modelByGroupKey.get(group.key);
|
||||
const renderedRows: ReadonlyArray<{
|
||||
entry: AgentTurnWorkSectionRow;
|
||||
visibilityPriority: number;
|
||||
}> = model
|
||||
? [
|
||||
...model.leadingRows.map((entry) => ({
|
||||
entry,
|
||||
visibilityPriority: 0
|
||||
})),
|
||||
...model.sections.flatMap((section) =>
|
||||
section.rows.map((entry) => ({
|
||||
entry,
|
||||
visibilityPriority:
|
||||
model.collapseEligible && section.kind === "work" ? 1 : 0
|
||||
}))
|
||||
)
|
||||
]
|
||||
: group.rows.map((entry) => ({ entry, visibilityPriority: 0 }));
|
||||
|
||||
for (const { entry, visibilityPriority } of renderedRows) {
|
||||
const row = entry.row;
|
||||
if (row.kind !== "message" || row.messages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const participantTurnIndex =
|
||||
participantTurnIndexByRowIndex.get(entry.rowIndex) ?? entry.rowIndex;
|
||||
let candidateBySpeaker = headerCandidateByTurn.get(participantTurnIndex);
|
||||
if (!candidateBySpeaker) {
|
||||
candidateBySpeaker = new Map();
|
||||
headerCandidateByTurn.set(participantTurnIndex, candidateBySpeaker);
|
||||
}
|
||||
const currentCandidate = candidateBySpeaker.get(row.speaker);
|
||||
if (
|
||||
currentCandidate &&
|
||||
currentCandidate.visibilityPriority <= visibilityPriority
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidateBySpeaker.set(row.speaker, {
|
||||
renderKey: entry.renderKey ?? rowKeys[entry.rowIndex] ?? row.id,
|
||||
visibilityPriority
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Set(
|
||||
[...headerCandidateByTurn.values()].flatMap((candidateBySpeaker) =>
|
||||
[...candidateBySpeaker.values()].map((candidate) => candidate.renderKey)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentTurnWorkSectionOptions {
|
||||
collapseIntermediateAssistantReplies?: boolean;
|
||||
liveFrozenAtUnixMs?: number | null;
|
||||
liveObservationGapPresentationState?:
|
||||
| AgentGUIObservationGap["presentationState"]
|
||||
| null;
|
||||
}
|
||||
|
||||
export function resolveAgentTurnTiming(
|
||||
turn: AgentActivityTurn | null | undefined,
|
||||
isActiveTurn: boolean,
|
||||
submittedAtUnixMs?: number | null
|
||||
): AgentTurnTiming | null {
|
||||
if (!turn || !Number.isFinite(turn.startedAtUnixMs)) {
|
||||
return null;
|
||||
}
|
||||
const timingStartedAtUnixMs =
|
||||
Number.isFinite(submittedAtUnixMs) &&
|
||||
(submittedAtUnixMs as number) <= turn.startedAtUnixMs
|
||||
? (submittedAtUnixMs as number)
|
||||
: turn.startedAtUnixMs;
|
||||
|
||||
if (turn.phase !== "settled") {
|
||||
return isActiveTurn
|
||||
? { kind: "live", startedAtUnixMs: timingStartedAtUnixMs }
|
||||
: null;
|
||||
}
|
||||
|
||||
const endUnixMs = turn.settledAtUnixMs;
|
||||
if (
|
||||
!Number.isFinite(endUnixMs) ||
|
||||
(endUnixMs as number) < timingStartedAtUnixMs
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "settled",
|
||||
elapsedSeconds: Math.max(
|
||||
0,
|
||||
Math.floor(((endUnixMs as number) - timingStartedAtUnixMs) / 1_000)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAgentTurnDuration(
|
||||
elapsedSeconds: number
|
||||
): AgentTurnDuration {
|
||||
const safeSeconds = Math.max(0, Math.floor(elapsedSeconds));
|
||||
if (safeSeconds < 60) {
|
||||
return { kind: "seconds", seconds: safeSeconds };
|
||||
}
|
||||
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const seconds = safeSeconds % 60;
|
||||
if (seconds === 0) {
|
||||
return { kind: "minutes", minutes };
|
||||
}
|
||||
return { kind: "minutes-seconds", minutes, seconds };
|
||||
}
|
||||
|
||||
export function buildAgentTurnWorkSectionModel(
|
||||
group: AgentTranscriptTurnGroup,
|
||||
turn: AgentActivityTurn | null | undefined,
|
||||
isActiveTurn = false,
|
||||
options: AgentTurnWorkSectionOptions = {}
|
||||
): AgentTurnWorkSectionModel | null {
|
||||
const leadingRowCount = countLeadingUserRows(group.rows);
|
||||
const leadingRows = group.rows.slice(0, leadingRowCount);
|
||||
const submittedAtUnixMs = resolveTurnSubmittedAtUnixMs(
|
||||
leadingRows,
|
||||
turn?.startedAtUnixMs
|
||||
);
|
||||
const timing = resolveAgentTurnTiming(turn, isActiveTurn, submittedAtUnixMs);
|
||||
if (!timing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalTarget = findFinalAssistantTextTarget(group.rows);
|
||||
const sections = buildOrderedSections(
|
||||
group.rows,
|
||||
leadingRowCount,
|
||||
finalTarget,
|
||||
options.collapseIntermediateAssistantReplies === true
|
||||
);
|
||||
const hasHiddenWork = sections.some(
|
||||
(section) => section.kind === "work" && section.rows.length > 0
|
||||
);
|
||||
const collapseEligible =
|
||||
finalTarget !== null &&
|
||||
turn?.phase === "settled" &&
|
||||
turn.outcome === "completed" &&
|
||||
hasHiddenWork &&
|
||||
!groupContainsBlockingMessage(group) &&
|
||||
!group.rows.some(({ row }) => row.kind === "generated-image");
|
||||
|
||||
return {
|
||||
timing:
|
||||
timing.kind === "live"
|
||||
? {
|
||||
...timing,
|
||||
...(Number.isFinite(options.liveFrozenAtUnixMs) &&
|
||||
(options.liveFrozenAtUnixMs as number) >= timing.startedAtUnixMs
|
||||
? { frozenAtUnixMs: options.liveFrozenAtUnixMs as number }
|
||||
: {}),
|
||||
...(options.liveObservationGapPresentationState
|
||||
? {
|
||||
observationGapPresentationState:
|
||||
options.liveObservationGapPresentationState
|
||||
}
|
||||
: {})
|
||||
}
|
||||
: timing,
|
||||
leadingRows,
|
||||
sections,
|
||||
collapseEligible
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTurnSubmittedAtUnixMs(
|
||||
leadingRows: readonly AgentTurnWorkSectionRow[],
|
||||
fallbackUnixMs: number | null | undefined
|
||||
): number | null {
|
||||
const exactTimestamps = leadingRows.flatMap(({ row }) => {
|
||||
if (row.kind !== "message" || row.speaker !== "user") {
|
||||
return [];
|
||||
}
|
||||
return row.messages.flatMap((message) =>
|
||||
(message.sourceTimelineItems ?? []).flatMap((item) => {
|
||||
const value = item.payload?.clientSubmittedAtUnixMs;
|
||||
return validUnixMs(value) ? [value] : [];
|
||||
})
|
||||
);
|
||||
});
|
||||
const exact = minimumTimestamp(exactTimestamps);
|
||||
if (exact !== null) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const projected = minimumTimestamp(
|
||||
leadingRows.flatMap(({ row }) =>
|
||||
row.kind === "message" && row.speaker === "user"
|
||||
? [
|
||||
row.occurredAtUnixMs,
|
||||
...row.messages.map((message) => message.occurredAtUnixMs)
|
||||
]
|
||||
: []
|
||||
)
|
||||
);
|
||||
return projected ?? (validUnixMs(fallbackUnixMs) ? fallbackUnixMs : null);
|
||||
}
|
||||
|
||||
function minimumTimestamp(
|
||||
values: readonly (number | null | undefined)[]
|
||||
): number | null {
|
||||
const valid = values.filter(validUnixMs);
|
||||
return valid.length > 0 ? Math.min(...valid) : null;
|
||||
}
|
||||
|
||||
function validUnixMs(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function countLeadingUserRows(
|
||||
rows: readonly AgentTurnWorkSectionRow[]
|
||||
): number {
|
||||
let count = 0;
|
||||
while (isUserMessageRow(rows[count]?.row)) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function buildOrderedSections(
|
||||
rows: readonly AgentTurnWorkSectionRow[],
|
||||
startIndex: number,
|
||||
finalTarget: { rowIndex: number; messageIndex: number } | null,
|
||||
collapseIntermediateAssistantReplies: boolean
|
||||
): AgentTurnWorkSectionSegment[] {
|
||||
const sections: AgentTurnWorkSectionSegment[] = [];
|
||||
for (let rowIndex = startIndex; rowIndex < rows.length; rowIndex += 1) {
|
||||
const entry = rows[rowIndex]!;
|
||||
if (entry.row.kind === "message" && entry.row.speaker === "assistant") {
|
||||
if (!collapseIntermediateAssistantReplies) {
|
||||
appendAssistantMessageSections(sections, entry);
|
||||
} else if (finalTarget?.rowIndex === rowIndex) {
|
||||
appendFinalAssistantSections(sections, entry, finalTarget.messageIndex);
|
||||
} else {
|
||||
appendSectionRow(sections, "work", entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
appendSectionRow(
|
||||
sections,
|
||||
isExplicitWorkRow(entry.row) ? "work" : "visible",
|
||||
entry
|
||||
);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendFinalAssistantSections(
|
||||
sections: AgentTurnWorkSectionSegment[],
|
||||
sourceEntry: AgentTurnWorkSectionRow,
|
||||
finalMessageIndex: number
|
||||
): void {
|
||||
const sourceRow = sourceEntry.row as AgentMessageRowVM;
|
||||
const messagesBeforeFinal = sourceRow.messages.slice(0, finalMessageIndex);
|
||||
const messagesAfterFinal = sourceRow.messages.slice(finalMessageIndex + 1);
|
||||
|
||||
if (sourceRow.thinking.length > 0 || messagesBeforeFinal.length > 0) {
|
||||
appendSectionRow(sections, "work", {
|
||||
...sourceEntry,
|
||||
renderKey: `${sourceRow.id}:turn-work-before`,
|
||||
row: cloneAssistantRow(sourceRow, messagesBeforeFinal, sourceRow.thinking)
|
||||
});
|
||||
}
|
||||
|
||||
appendSectionRow(sections, "visible", {
|
||||
...sourceEntry,
|
||||
renderKey: `${sourceRow.id}:turn-final`,
|
||||
row: cloneAssistantRow(
|
||||
sourceRow,
|
||||
[sourceRow.messages[finalMessageIndex]!],
|
||||
[]
|
||||
)
|
||||
});
|
||||
|
||||
if (messagesAfterFinal.length > 0) {
|
||||
appendSectionRow(sections, "work", {
|
||||
...sourceEntry,
|
||||
renderKey: `${sourceRow.id}:turn-work-after`,
|
||||
row: cloneAssistantRow(sourceRow, messagesAfterFinal, [])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendAssistantMessageSections(
|
||||
sections: AgentTurnWorkSectionSegment[],
|
||||
sourceEntry: AgentTurnWorkSectionRow
|
||||
): void {
|
||||
const sourceRow = sourceEntry.row as AgentMessageRowVM;
|
||||
const parts: Array<{
|
||||
kind: AgentTurnWorkSectionSegment["kind"];
|
||||
messages: AgentMessageContentVM[];
|
||||
thinking: AgentMessageRowVM["thinking"];
|
||||
}> = [];
|
||||
|
||||
if (sourceRow.thinking.length > 0) {
|
||||
parts.push({ kind: "work", messages: [], thinking: sourceRow.thinking });
|
||||
}
|
||||
|
||||
for (const message of sourceRow.messages) {
|
||||
const kind = isExplicitWorkMessage(message) ? "work" : "visible";
|
||||
const previous = parts.at(-1);
|
||||
if (previous?.kind === kind && previous.thinking.length === 0) {
|
||||
previous.messages.push(message);
|
||||
continue;
|
||||
}
|
||||
parts.push({ kind, messages: [message], thinking: [] });
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
appendSectionRow(sections, "visible", sourceEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length === 1 && sourceRow.thinking.length === 0) {
|
||||
appendSectionRow(sections, parts[0]!.kind, sourceEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
parts.forEach((part, partIndex) => {
|
||||
appendSectionRow(sections, part.kind, {
|
||||
...sourceEntry,
|
||||
renderKey: `${sourceRow.id}:turn-${part.kind}-${partIndex}`,
|
||||
row: cloneAssistantRow(sourceRow, part.messages, part.thinking)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function appendSectionRow(
|
||||
sections: AgentTurnWorkSectionSegment[],
|
||||
kind: AgentTurnWorkSectionSegment["kind"],
|
||||
row: AgentTurnWorkSectionRow
|
||||
): void {
|
||||
const previous = sections.at(-1);
|
||||
if (previous?.kind === kind) {
|
||||
previous.rows.push(row);
|
||||
return;
|
||||
}
|
||||
sections.push({ kind, rows: [row] });
|
||||
}
|
||||
|
||||
function findFinalAssistantTextTarget(
|
||||
rows: readonly AgentTurnWorkSectionRow[]
|
||||
): { rowIndex: number; messageIndex: number } | null {
|
||||
for (let rowIndex = rows.length - 1; rowIndex >= 0; rowIndex -= 1) {
|
||||
const row = rows[rowIndex]?.row;
|
||||
if (row?.kind !== "message" || row.speaker !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let messageIndex = row.messages.length - 1;
|
||||
messageIndex >= 0;
|
||||
messageIndex -= 1
|
||||
) {
|
||||
const message = row.messages[messageIndex];
|
||||
if (
|
||||
message?.isTurnFinalText &&
|
||||
message.body.trim() &&
|
||||
message.contentKind !== "image-grid" &&
|
||||
!message.visibleError &&
|
||||
!message.systemNotice
|
||||
) {
|
||||
return { rowIndex, messageIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupContainsBlockingMessage(
|
||||
group: AgentTranscriptTurnGroup
|
||||
): boolean {
|
||||
return group.rows.some(
|
||||
({ row }) =>
|
||||
row.kind === "message" &&
|
||||
row.messages.some((message) =>
|
||||
Boolean(
|
||||
message.visibleError ||
|
||||
(message.systemNotice && message.presentationKind === "content")
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isExplicitWorkRow(row: AgentTurnWorkSectionRow["row"]): boolean {
|
||||
return row.kind === "tool-group" || row.kind === "processing";
|
||||
}
|
||||
|
||||
function isExplicitWorkMessage(message: AgentMessageContentVM): boolean {
|
||||
return (
|
||||
message.presentationKind === "specific-progress" ||
|
||||
message.presentationKind === "turn-boundary"
|
||||
);
|
||||
}
|
||||
|
||||
function isUserMessageRow(
|
||||
row: AgentTurnWorkSectionRow["row"] | undefined
|
||||
): row is AgentMessageRowVM {
|
||||
return row?.kind === "message" && row.speaker === "user";
|
||||
}
|
||||
|
||||
function cloneAssistantRow(
|
||||
source: AgentMessageRowVM,
|
||||
messages: AgentMessageContentVM[],
|
||||
thinking: AgentMessageRowVM["thinking"]
|
||||
): AgentMessageRowVM {
|
||||
return {
|
||||
...source,
|
||||
messages,
|
||||
thinking
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user