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
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
import type {
|
|
AgentActivityMessage,
|
|
AgentActivitySession
|
|
} from "@tutti-os/agent-activity-core";
|
|
import type {
|
|
AgentTaskSubAgentActivityVM,
|
|
AgentTaskSubAgentStatus,
|
|
AgentTaskSubAgentVM
|
|
} from "../contracts/agentTaskItemVM";
|
|
import type { AgentConversationVM } from "../contracts/agentConversationVM";
|
|
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
|
|
import type { AgentToolGroupRowVM } from "../contracts/agentToolGroupRowVM";
|
|
import { projectWorkspaceAgentMessagesToTimelineItems } from "./workspaceAgentMessageProjection";
|
|
import type { WorkspaceAgentActivityTimelineItem } from "../../workspaceAgentTimelineTypes";
|
|
import {
|
|
isWorkspaceAgentToolCallItem,
|
|
resolveWorkspaceAgentToolName
|
|
} from "../../workspaceAgentToolCallDisplay";
|
|
|
|
export interface BuildChildSessionLanesInput {
|
|
rootSession: AgentActivitySession;
|
|
rootTimelineItems: readonly WorkspaceAgentActivityTimelineItem[];
|
|
childSessions: readonly AgentActivitySession[];
|
|
messagesBySessionId: Readonly<
|
|
Record<string, readonly AgentActivityMessage[] | undefined>
|
|
>;
|
|
}
|
|
|
|
export function buildChildSessionLanesByParentToolCallId(
|
|
input: BuildChildSessionLanesInput
|
|
): ReadonlyMap<string, AgentTaskSubAgentVM[]> {
|
|
const rootSessionId = input.rootSession.agentSessionId.trim();
|
|
const childrenByParentSessionId = new Map<string, AgentActivitySession[]>();
|
|
for (const childSession of input.childSessions) {
|
|
if (
|
|
childSession.kind !== "child" ||
|
|
childSession.rootAgentSessionId !== rootSessionId ||
|
|
!childSession.parentAgentSessionId?.trim() ||
|
|
!childSession.parentToolCallId?.trim()
|
|
) {
|
|
continue;
|
|
}
|
|
const parentSessionId = childSession.parentAgentSessionId.trim();
|
|
const siblings = childrenByParentSessionId.get(parentSessionId) ?? [];
|
|
siblings.push(childSession);
|
|
childrenByParentSessionId.set(parentSessionId, siblings);
|
|
}
|
|
for (const siblings of childrenByParentSessionId.values()) {
|
|
siblings.sort(compareChildSessions);
|
|
}
|
|
|
|
const lanesByParentToolCallId = new Map<string, AgentTaskSubAgentVM[]>();
|
|
const directChildren = childrenByParentSessionId.get(rootSessionId) ?? [];
|
|
const visited = new Set<string>();
|
|
for (const childSession of directChildren) {
|
|
const parentToolCallId = childSession.parentToolCallId?.trim();
|
|
if (!parentToolCallId) continue;
|
|
const lanes = lanesByParentToolCallId.get(parentToolCallId) ?? [];
|
|
lanes.push(
|
|
buildChildSessionLane({
|
|
childSession,
|
|
childrenByParentSessionId,
|
|
messagesBySessionId: input.messagesBySessionId,
|
|
parentTimelineItems: input.rootTimelineItems,
|
|
visited
|
|
})
|
|
);
|
|
lanesByParentToolCallId.set(parentToolCallId, lanes);
|
|
}
|
|
for (const lanes of lanesByParentToolCallId.values()) {
|
|
assignLanePositions(lanes);
|
|
}
|
|
return lanesByParentToolCallId;
|
|
}
|
|
|
|
export function attachChildSessionLanesToConversationVM(
|
|
conversation: AgentConversationVM | null,
|
|
lanesByParentToolCallId: ReadonlyMap<string, AgentTaskSubAgentVM[]>
|
|
): AgentConversationVM | null {
|
|
if (!conversation || lanesByParentToolCallId.size === 0) {
|
|
return conversation;
|
|
}
|
|
let changed = false;
|
|
const rows = conversation.rows.map((row) => {
|
|
if (row.kind !== "tool-group") return row;
|
|
const nextRow = toolGroupRowWithChildSessions(row, lanesByParentToolCallId);
|
|
if (nextRow !== row) changed = true;
|
|
return nextRow;
|
|
});
|
|
return changed ? { ...conversation, rows } : conversation;
|
|
}
|
|
|
|
export function deriveSubAgentNameFromTask(task: string | null): string | null {
|
|
if (!task) return null;
|
|
const firstSentence = task
|
|
.trim()
|
|
.split(/[。..!?!?,,\n]/, 1)[0]
|
|
?.trim();
|
|
if (!firstSentence) return null;
|
|
const selfAddress = /^(你现在是|你現在是|你是|you are|act as)\s*/i;
|
|
if (!selfAddress.test(firstSentence)) return null;
|
|
const stripped = firstSentence
|
|
.replace(selfAddress, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return stripped.length >= 2 && stripped.length <= 36 ? stripped : null;
|
|
}
|
|
|
|
function buildChildSessionLane(input: {
|
|
childSession: AgentActivitySession;
|
|
childrenByParentSessionId: ReadonlyMap<string, AgentActivitySession[]>;
|
|
messagesBySessionId: BuildChildSessionLanesInput["messagesBySessionId"];
|
|
parentTimelineItems: readonly WorkspaceAgentActivityTimelineItem[];
|
|
visited: Set<string>;
|
|
}): AgentTaskSubAgentVM {
|
|
const childSessionId = input.childSession.agentSessionId.trim();
|
|
if (input.visited.has(childSessionId)) {
|
|
return emptyCycleLane(input.childSession);
|
|
}
|
|
input.visited.add(childSessionId);
|
|
const childMessages = input.messagesBySessionId[childSessionId] ?? [];
|
|
const childTimelineItems =
|
|
projectWorkspaceAgentMessagesToTimelineItems(childMessages);
|
|
const parentToolCallId = input.childSession.parentToolCallId?.trim() ?? "";
|
|
const parentCall = parentToolCall(
|
|
input.parentTimelineItems,
|
|
parentToolCallId
|
|
);
|
|
const task = parentCall?.task ?? null;
|
|
const childSessions = (
|
|
input.childrenByParentSessionId.get(childSessionId) ?? []
|
|
).map((nestedChild) =>
|
|
buildChildSessionLane({
|
|
childSession: nestedChild,
|
|
childrenByParentSessionId: input.childrenByParentSessionId,
|
|
messagesBySessionId: input.messagesBySessionId,
|
|
parentTimelineItems: childTimelineItems,
|
|
visited: input.visited
|
|
})
|
|
);
|
|
assignSiblingLanePositions(childSessions);
|
|
input.visited.delete(childSessionId);
|
|
|
|
const activity = childSessionActivityLog(childTimelineItems);
|
|
const latestActivity = activity.entries.at(-1) ?? null;
|
|
const latestMessageAtUnixMs = childMessages.reduce(
|
|
(latest, message) => Math.max(latest, message.occurredAtUnixMs),
|
|
0
|
|
);
|
|
const latestTurn = input.childSession.latestTurn;
|
|
const status = childSessionStatus(input.childSession);
|
|
const assistantMarkdown = childSessionAssistantMarkdown(
|
|
childTimelineItems,
|
|
childMessages
|
|
);
|
|
const terminalAtUnixMs =
|
|
status === "running"
|
|
? null
|
|
: (latestTurn?.settledAtUnixMs ?? input.childSession.endedAtUnixMs);
|
|
return {
|
|
childSessionId,
|
|
parentToolCallId,
|
|
status,
|
|
name:
|
|
normalizedString(input.childSession.title) ??
|
|
deriveSubAgentNameFromTask(task),
|
|
task,
|
|
laneIndex: 1,
|
|
laneCount: 1,
|
|
latestActivity: latestActivity?.text ?? null,
|
|
latestActivityKind: latestActivity?.kind ?? null,
|
|
assistantMarkdown,
|
|
activityLog: activity.entries,
|
|
activityOmittedCount: activity.omittedCount,
|
|
queued: input.childSession.activeTurn?.phase === "submitted",
|
|
failureDetail:
|
|
status === "failed" ? (latestTurn?.error?.message?.trim() ?? null) : null,
|
|
startedAtUnixMs:
|
|
input.childSession.activeTurn?.startedAtUnixMs ??
|
|
latestTurn?.startedAtUnixMs ??
|
|
input.childSession.createdAtUnixMs,
|
|
latestActivityAtUnixMs: Math.max(
|
|
latestMessageAtUnixMs,
|
|
latestTurn?.updatedAtUnixMs ?? 0,
|
|
input.childSession.updatedAtUnixMs
|
|
),
|
|
terminalAtUnixMs,
|
|
childSessions
|
|
};
|
|
}
|
|
|
|
function emptyCycleLane(session: AgentActivitySession): AgentTaskSubAgentVM {
|
|
return {
|
|
childSessionId: session.agentSessionId,
|
|
parentToolCallId: session.parentToolCallId ?? "",
|
|
status: childSessionStatus(session),
|
|
name: normalizedString(session.title),
|
|
task: null,
|
|
laneIndex: 1,
|
|
laneCount: 1,
|
|
latestActivity: null,
|
|
latestActivityKind: null,
|
|
assistantMarkdown: null,
|
|
activityLog: [],
|
|
activityOmittedCount: 0,
|
|
failureDetail: session.latestTurn?.error?.message?.trim() ?? null,
|
|
startedAtUnixMs: session.createdAtUnixMs,
|
|
latestActivityAtUnixMs: session.updatedAtUnixMs,
|
|
terminalAtUnixMs:
|
|
session.latestTurn?.settledAtUnixMs ?? session.endedAtUnixMs,
|
|
childSessions: []
|
|
};
|
|
}
|
|
|
|
function childSessionStatus(
|
|
session: AgentActivitySession
|
|
): AgentTaskSubAgentStatus {
|
|
const activeTurn = session.activeTurn;
|
|
if (activeTurn && activeTurn.phase !== "settled") return "running";
|
|
const latestTurn = session.latestTurn;
|
|
if (!latestTurn || latestTurn.phase !== "settled") return "running";
|
|
switch (latestTurn.outcome) {
|
|
case "failed":
|
|
return "failed";
|
|
case "canceled":
|
|
case "interrupted":
|
|
return "canceled";
|
|
case "completed":
|
|
default:
|
|
return "completed";
|
|
}
|
|
}
|
|
|
|
function parentToolCall(
|
|
timelineItems: readonly WorkspaceAgentActivityTimelineItem[],
|
|
parentToolCallId: string
|
|
): { task: string | null } | null {
|
|
if (!parentToolCallId) return null;
|
|
for (const item of timelineItems) {
|
|
if (!isWorkspaceAgentToolCallItem(item)) continue;
|
|
const callId = firstString(item.callId, stringValue(item.payload?.callId));
|
|
if (callId !== parentToolCallId) continue;
|
|
const input = recordValue(item.payload?.input);
|
|
return {
|
|
task: firstString(
|
|
stringValue(input?.task),
|
|
stringValue(input?.prompt),
|
|
stringValue(input?.description)
|
|
)
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toolGroupRowWithChildSessions(
|
|
row: AgentToolGroupRowVM,
|
|
lanesByParentToolCallId: ReadonlyMap<string, AgentTaskSubAgentVM[]>
|
|
): AgentToolGroupRowVM {
|
|
let changed = false;
|
|
const callsById = new Map<string, AgentToolCallVM>();
|
|
const calls = row.calls.map((call) => {
|
|
const lanes = call.task
|
|
? lanesByParentToolCallId.get(toolCallRawId(call.id))
|
|
: null;
|
|
if (!lanes?.length || !call.task) return call;
|
|
changed = true;
|
|
const nextCall: AgentToolCallVM = {
|
|
...call,
|
|
task: { ...call.task, subAgents: lanes }
|
|
};
|
|
callsById.set(nextCall.id, nextCall);
|
|
return nextCall;
|
|
});
|
|
if (!changed) return row;
|
|
const entries = row.entries.map((entry) => {
|
|
if (entry.kind !== "tool-call") return entry;
|
|
const nextCall = callsById.get(entry.call.id);
|
|
return nextCall ? { ...entry, call: nextCall } : entry;
|
|
});
|
|
return { ...row, calls, entries };
|
|
}
|
|
|
|
function toolCallRawId(id: string): string {
|
|
return id.startsWith("call:") ? id.slice("call:".length) : id;
|
|
}
|
|
|
|
const CHILD_ACTIVITY_LOG_CAP = 20;
|
|
|
|
function childSessionActivityLog(
|
|
timelineItems: readonly WorkspaceAgentActivityTimelineItem[]
|
|
): { entries: AgentTaskSubAgentActivityVM[]; omittedCount: number } {
|
|
const entries = timelineItems
|
|
.map(displayableActivityEntry)
|
|
.filter((entry): entry is AgentTaskSubAgentActivityVM => entry !== null);
|
|
const omittedCount = Math.max(0, entries.length - CHILD_ACTIVITY_LOG_CAP);
|
|
return { entries: entries.slice(omittedCount), omittedCount };
|
|
}
|
|
|
|
function displayableActivityEntry(
|
|
item: WorkspaceAgentActivityTimelineItem
|
|
): AgentTaskSubAgentActivityVM | null {
|
|
const atUnixMs = timelineItemTime(item) || null;
|
|
if (isWorkspaceAgentToolCallItem(item)) {
|
|
const name = firstString(
|
|
resolveWorkspaceAgentToolName(item),
|
|
item.name,
|
|
stringValue(item.payload?.name)
|
|
);
|
|
return name ? { kind: "tool", text: name, atUnixMs } : null;
|
|
}
|
|
const text = snippet(timelineItemText(item));
|
|
if (!text) return null;
|
|
const itemType = item.itemType?.trim().toLowerCase() ?? "";
|
|
const role = item.role?.trim().toLowerCase() ?? "";
|
|
return itemType === "message.assistant_thinking" ||
|
|
role === "assistant_thinking"
|
|
? { kind: "reasoning", text, atUnixMs }
|
|
: { kind: "message", text, atUnixMs };
|
|
}
|
|
|
|
function timelineItemText(item: WorkspaceAgentActivityTimelineItem): string {
|
|
return (
|
|
stringValue(item.payload?.text) ??
|
|
stringValue(item.payload?.content) ??
|
|
stringValue(item.content) ??
|
|
""
|
|
)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function childSessionAssistantMarkdown(
|
|
timelineItems: readonly WorkspaceAgentActivityTimelineItem[],
|
|
messages: readonly AgentActivityMessage[]
|
|
): string | null {
|
|
const assistantTextMessageIds = new Set(
|
|
messages
|
|
.filter((message) => {
|
|
const kind = message.kind.trim().toLowerCase();
|
|
const role = message.role.trim().toLowerCase();
|
|
const payloadKind =
|
|
typeof message.payload?.kind === "string"
|
|
? message.payload.kind.trim().toLowerCase()
|
|
: "";
|
|
return (
|
|
kind === "text" &&
|
|
(role === "assistant" || role === "agent") &&
|
|
message.semantics?.noticeCommand !== "compact" &&
|
|
message.payload?.noticeCommand !== "compact" &&
|
|
payloadKind !== "agent_system_notice" &&
|
|
payloadKind !== "agent_visible_error"
|
|
);
|
|
})
|
|
.map((message) => message.messageId.trim())
|
|
.filter(Boolean)
|
|
);
|
|
for (let index = timelineItems.length - 1; index >= 0; index -= 1) {
|
|
const item = timelineItems[index];
|
|
if (!item) continue;
|
|
const itemType = item.itemType?.trim().toLowerCase() ?? "";
|
|
const role = item.role?.trim().toLowerCase() ?? "";
|
|
const payloadKind =
|
|
typeof item.payload?.kind === "string"
|
|
? item.payload.kind.trim().toLowerCase()
|
|
: "";
|
|
if (
|
|
role !== "assistant" ||
|
|
itemType !== "message.assistant" ||
|
|
!assistantTextMessageIds.has(item.eventId.trim()) ||
|
|
typeof item.payload?.noticeKind === "string" ||
|
|
payloadKind === "agent_visible_error" ||
|
|
payloadKind === "agent_system_notice"
|
|
) {
|
|
continue;
|
|
}
|
|
const markdown =
|
|
markdownStringValue(item.payload?.text) ??
|
|
markdownStringValue(item.payload?.content) ??
|
|
markdownStringValue(item.content);
|
|
if (markdown) return markdown;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function snippet(text: string): string {
|
|
return text.length <= 140
|
|
? text
|
|
: String.fromCodePoint(0x2026) + text.slice(-140);
|
|
}
|
|
|
|
function assignSiblingLanePositions(lanes: AgentTaskSubAgentVM[]): void {
|
|
const lanesByCallId = new Map<string, AgentTaskSubAgentVM[]>();
|
|
for (const lane of lanes) {
|
|
const siblings = lanesByCallId.get(lane.parentToolCallId) ?? [];
|
|
siblings.push(lane);
|
|
lanesByCallId.set(lane.parentToolCallId, siblings);
|
|
}
|
|
for (const siblings of lanesByCallId.values()) assignLanePositions(siblings);
|
|
}
|
|
|
|
function assignLanePositions(lanes: AgentTaskSubAgentVM[]): void {
|
|
for (let index = 0; index < lanes.length; index += 1) {
|
|
const lane = lanes[index];
|
|
if (!lane) continue;
|
|
lane.laneIndex = index + 1;
|
|
lane.laneCount = lanes.length;
|
|
}
|
|
}
|
|
|
|
function compareChildSessions(
|
|
left: AgentActivitySession,
|
|
right: AgentActivitySession
|
|
): number {
|
|
return (
|
|
left.createdAtUnixMs - right.createdAtUnixMs ||
|
|
left.agentSessionId.localeCompare(right.agentSessionId)
|
|
);
|
|
}
|
|
|
|
function timelineItemTime(
|
|
item: WorkspaceAgentActivityTimelineItem | undefined
|
|
): number {
|
|
return item?.occurredAtUnixMs ?? item?.createdAtUnixMs ?? 0;
|
|
}
|
|
|
|
function normalizedString(value: string | null | undefined): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function stringValue(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function markdownStringValue(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value : null;
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function firstString(
|
|
...values: Array<string | null | undefined>
|
|
): string | null {
|
|
for (const value of values) {
|
|
const normalized = normalizedString(value);
|
|
if (normalized) return normalized;
|
|
}
|
|
return null;
|
|
}
|