Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,56 @@ function makeThread(
};
}

function makeToolLifecycleActivity(
id: string,
sequence: number,
turnId: ReturnType<typeof TurnId.make>,
kind: "tool.updated" | "tool.completed",
toolCallId?: string,
) {
const title = "Run tests";
return makeActivity({
id: EventId.make(id),
kind,
tone: "tool",
summary: title,
createdAt: `2026-04-01T00:00:0${sequence}.000Z`,
turnId,
payload: {
title,
itemType: "command_execution",
...(toolCallId ? { toolCallId } : {}),
},
});
}

function makeToolLifecycleThread(
id: string,
activities: ReadonlyArray<
readonly [
id: string,
turnId: ReturnType<typeof TurnId.make>,
kind: "tool.updated" | "tool.completed",
toolCallId?: string,
]
>,
) {
return makeThread({
id: ThreadId.make(`thread-${id}`),
projectId: ProjectId.make("project-1"),
title: id,
activities: activities.map(([activityId, turnId, kind, toolCallId], index) =>
makeToolLifecycleActivity(activityId, index + 1, turnId, kind, toolCallId),
),
});
}

function threadActivities(thread: OrchestrationThread): ThreadFeedActivity[] {
return buildThreadFeed(thread).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);
}

describe("buildThreadFeed", () => {
it("keeps historic work entries attributed to their turns", () => {
const thread = makeThread({
Expand Down Expand Up @@ -227,6 +277,7 @@ describe("buildThreadFeed", () => {
payload: {
title: "Run tests",
itemType: "command_execution",
toolCallId: "call-1",
detail: "/bin/zsh -lc 'bun run test'",
},
}),
Expand Down Expand Up @@ -274,6 +325,157 @@ describe("buildThreadFeed", () => {
);
});

it("keeps ambiguous id-less mobile completions separate", () => {
const turnId = TurnId.make("turn-ambiguous-tools");
const thread = makeToolLifecycleThread("ambiguous-tools", [
["tool-a-updated", turnId, "tool.updated", "call-a"],
["tool-b-updated", turnId, "tool.updated", "call-b"],
["tool-completed", turnId, "tool.completed"],
]);

expect(threadActivities(thread).map((entry) => entry.id)).toEqual([
"tool-a-updated",
"tool-b-updated",
"tool-completed",
]);
});

it("ignores prior turns when matching an id-less mobile completion", () => {
const previousTurnId = TurnId.make("turn-previous-tool");
const currentTurnId = TurnId.make("turn-current-tool");
const thread = makeToolLifecycleThread("prior-turn-tool", [
["prior-tool-updated", previousTurnId, "tool.updated", "prior-call"],
["current-tool-updated", currentTurnId, "tool.updated", "current-call"],
["current-tool-completed", currentTurnId, "tool.completed"],
]);

expect(threadActivities(thread).map((entry) => entry.id)).toEqual([
"prior-tool-updated",
"current-tool-completed",
]);
});

it("keeps an id-less mobile completion terminal after a late keyed update", () => {
const turnId = TurnId.make("turn-late-tool-update");
const thread = makeToolLifecycleThread("late-tool-update", [
["tool-updated", turnId, "tool.updated", "call-1"],
["tool-completed", turnId, "tool.completed"],
["tool-late-update", turnId, "tool.updated", "call-1"],
]);

expect(threadActivities(thread)).toMatchObject([{ id: "tool-completed", status: "success" }]);
});

it("collapses interleaved and late lifecycle rows by top-level tool identity", () => {
const turnId = TurnId.make("turn-interleaved-tools");
const lifecycleActivity = (
id: string,
createdAt: string,
kind: "tool.updated" | "tool.completed",
toolCallId: string,
title: string,
status?: "inProgress" | "completed" | "failed" | "declined" | "stopped",
) =>
makeActivity({
id: EventId.make(id),
kind,
tone: "tool",
summary: title,
createdAt,
turnId,
payload: {
itemType: "command_execution",
toolCallId,
title,
detail: title,
...(status ? { status } : {}),
},
});
const thread = makeThread({
id: ThreadId.make("thread-interleaved-tools"),
projectId: ProjectId.make("project-1"),
title: "Interleaved tools",
latestTurn: {
turnId,
state: "completed",
requestedAt: "2026-04-01T00:00:00.000Z",
startedAt: "2026-04-01T00:00:01.000Z",
completedAt: "2026-04-01T00:00:05.000Z",
assistantMessageId: null,
},
activities: [
lifecycleActivity(
"tool-a-updated",
"2026-04-01T00:00:01.000Z",
"tool.updated",
"call-a",
"Preparing first call",
),
lifecycleActivity(
"tool-b-updated",
"2026-04-01T00:00:02.000Z",
"tool.updated",
"call-b",
"Preparing second call",
),
lifecycleActivity(
"tool-a-completed",
"2026-04-01T00:00:03.000Z",
"tool.completed",
"call-a",
"First call complete",
),
lifecycleActivity(
"tool-a-late-updated",
"2026-04-01T00:00:03.500Z",
"tool.updated",
"call-a",
"Late first update",
),
lifecycleActivity(
"tool-a-completed-duplicate",
"2026-04-01T00:00:03.750Z",
"tool.completed",
"call-a",
"First call complete",
),
lifecycleActivity(
"tool-c-failed",
"2026-04-01T00:00:03.875Z",
"tool.updated",
"call-c",
"Third call failed",
"failed",
),
lifecycleActivity(
"tool-c-late-updated",
"2026-04-01T00:00:03.900Z",
"tool.updated",
"call-c",
"Late third update",
"inProgress",
),
lifecycleActivity(
"tool-b-completed",
"2026-04-01T00:00:04.000Z",
"tool.completed",
"call-b",
"Second call complete",
),
],
});

const group = buildThreadFeed(thread)[0];
expect(group?.type).toBe("activity-group");
if (!group || group.type !== "activity-group") return;
expect(group.activities.map((activity) => activity.id)).toEqual([
"tool-a-completed-duplicate",
"tool-c-failed",
"tool-b-completed",
]);
expect(group.activities[1]?.status).toBe("failure");
});

it("keeps MCP inputs available to expanded mobile work rows", () => {
const turnId = TurnId.make("turn-mcp");
const thread = makeThread({
Expand Down
96 changes: 92 additions & 4 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface WorkLogEntry {
id: string;
createdAt: string;
turnId: TurnId | null;
toolCallId?: string;
label: string;
detail?: string;
command?: string;
Expand Down Expand Up @@ -352,6 +353,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const commandPreview = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const toolCallId = extractToolCallId(payload);
// task.updated included: terminal bypassed updates (Codex children's only
// terminal signal) must carry task identity so they collapse per child
// instead of stacking anonymous "Task idle" rows.
Expand Down Expand Up @@ -426,6 +428,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (requestKind) {
entry.requestKind = requestKind;
}
if (toolCallId) {
entry.toolCallId = toolCallId;
}
let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
if (!toolLifecycleStatus && activity.kind === "tool.completed") {
toolLifecycleStatus = "completed";
Expand All @@ -447,6 +452,7 @@ function collapseDerivedWorkLogEntries(
// Subagent rows collapse by identity, not adjacency (quiet-timeline
// guarantee; mirrors web's session-logic).
const taskRowIndex = new Map<string, number>();
const toolLifecycleRowIndex = new Map<string, number>();
for (const entry of entries) {
const isTaskRow =
entry.taskId !== undefined &&
Expand All @@ -463,12 +469,59 @@ function collapseDerivedWorkLogEntries(
collapsed.push(entry);
continue;
}
const lifecycleKey = entry.toolCallId ? entry.collapseKey : undefined;
if (lifecycleKey !== undefined) {
const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey);
const matchingEntry = matchingIndex !== undefined ? collapsed[matchingIndex] : undefined;
if (matchingIndex !== undefined && matchingEntry) {
if (workLogEntryHasTerminalToolLifecycle(matchingEntry)) {
collapsed[matchingIndex] = workLogEntryHasTerminalToolLifecycle(entry)
? mergeDerivedWorkLogEntries(matchingEntry, entry)
: mergeDerivedWorkLogEntries(entry, matchingEntry);
continue;
}
if (shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry);
continue;
}
toolLifecycleRowIndex.delete(lifecycleKey);
}
}
const previous = collapsed.at(-1);
if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
const hasCompetingIdlessCompletionTarget =
previous?.activityKind === "tool.updated" &&
entry.activityKind === "tool.completed" &&
entry.toolCallId === undefined &&
collapsed
.slice(0, -1)
.some(
(candidate) =>
candidate.activityKind !== "tool.completed" &&
candidate.turnId === previous.turnId &&
candidate.itemType === previous.itemType &&
normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) ===
normalizeCompactToolLabel(previous.toolTitle ?? previous.label),
);
if (
previous &&
!hasCompetingIdlessCompletionTarget &&
shouldCollapseToolLifecycleEntries(previous, entry)
) {
const previousIndex = collapsed.length - 1;
if (previous.toolCallId && previous.collapseKey) {
toolLifecycleRowIndex.delete(previous.collapseKey);
}
const merged = mergeDerivedWorkLogEntries(previous, entry);
collapsed[previousIndex] = merged;
if (merged.toolCallId && merged.collapseKey) {
toolLifecycleRowIndex.set(merged.collapseKey, previousIndex);
}
continue;
}
collapsed.push(entry);
if (lifecycleKey !== undefined) {
toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
}
}
return collapsed;
}
Expand All @@ -486,7 +539,29 @@ function shouldCollapseToolLifecycleEntries(
if (previous.activityKind === "tool.completed") {
return false;
}
return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey;
if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
return true;
}
return (
previous.activityKind === "tool.updated" &&
next.activityKind === "tool.completed" &&
previous.toolCallId !== undefined &&
next.toolCallId === undefined &&
previous.turnId === next.turnId &&
previous.itemType === next.itemType &&
normalizeCompactToolLabel(previous.toolTitle ?? previous.label) ===
normalizeCompactToolLabel(next.toolTitle ?? next.label)
);
}

function workLogEntryHasTerminalToolLifecycle(entry: DerivedWorkLogEntry): boolean {
return (
entry.activityKind === "tool.completed" ||
entry.toolLifecycleStatus === "completed" ||
entry.toolLifecycleStatus === "failed" ||
entry.toolLifecycleStatus === "declined" ||
entry.toolLifecycleStatus === "stopped"
);
}

function mergeDerivedWorkLogEntries(
Expand All @@ -500,7 +575,11 @@ function mergeDerivedWorkLogEntries(
const toolTitle = next.toolTitle ?? previous.toolTitle;
const itemType = next.itemType ?? previous.itemType;
const requestKind = next.requestKind ?? previous.requestKind;
const collapseKey = next.collapseKey ?? previous.collapseKey;
const collapseKey =
previous.toolCallId !== undefined && next.toolCallId === undefined
? previous.collapseKey
: (next.collapseKey ?? previous.collapseKey);
const toolCallId = next.toolCallId ?? previous.toolCallId;
const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
const toolData = next.toolData ?? previous.toolData;
return {
Expand All @@ -514,6 +593,7 @@ function mergeDerivedWorkLogEntries(
...(itemType ? { itemType } : {}),
...(requestKind ? { requestKind } : {}),
...(collapseKey ? { collapseKey } : {}),
...(toolCallId ? { toolCallId } : {}),
...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
...(toolData !== undefined ? { toolData } : {}),
};
Expand All @@ -534,6 +614,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
return undefined;
}
if (entry.toolCallId) {
return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
}
const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
const detail = entry.detail?.trim() ?? "";
const itemType = entry.itemType ?? "";
Expand Down Expand Up @@ -915,6 +998,11 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
return asTrimmedString(payload?.title);
}

function extractToolCallId(payload: Record<string, unknown> | null): string | null {
const data = asRecord(payload?.data);
return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
}

function extractWorkLogToolLifecycleStatus(
payload: Record<string, unknown> | null,
): WorkLogToolLifecycleStatus | undefined {
Expand Down
Loading
Loading