Skip to content
Closed
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
94 changes: 94 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,100 @@ function makeThread(
}

describe("buildThreadFeed", () => {
it("keeps completed tool rows stable when another activity arrives", () => {
const turnId = TurnId.make("tool-history");
const started = makeActivity({
id: EventId.make("tool-start"),
kind: "tool.updated",
summary: "Run checks",
createdAt: "2026-04-01T00:00:01.000Z",
turnId,
payload: {
toolCallId: "checks",
itemType: "command_execution",
data: { command: "vp test" },
status: "inProgress",
},
});
const completed = makeActivity({
...started,
id: EventId.make("tool-complete"),
kind: "tool.completed",
createdAt: "2026-04-01T00:00:02.000Z",
payload: { toolCallId: "checks", status: "completed", detail: "All checks passed" },
});
const thread = { messages: [], activities: [started, completed] };
const rows = (activities: ReadonlyArray<OrchestrationThreadActivity>) =>
buildThreadFeed({ ...thread, activities }).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);
const previous = rows(thread.activities)[0]!;
const appended = makeActivity({
id: EventId.make("next-tool"),
kind: "tool.completed",
summary: "Read file",
createdAt: "2026-04-01T00:00:03.000Z",
turnId,
payload: { toolCallId: "read", itemType: "file_read", status: "completed" },
});
const next = rows([...thread.activities, appended]);
expect(next).toHaveLength(2);
expect(next[0]).toBe(previous);
expect(next[0]?.getCopyText()).toContain("All checks passed");
expect(next[0]?.status).toBe("success");

const failed = {
...completed,
payload: { toolCallId: "checks", status: "failed", detail: "Checks failed" },
};
const updated = rows([started, failed, appended]);
expect(updated[0]).not.toBe(previous);
expect(updated[0]?.status).toBe("failure");
expect(updated[0]?.getCopyText()).toContain("Checks failed");
expect(updated[1]).toBe(next[1]);
});

it("recomputes a tool merge when pagination changes its earlier activity", () => {
const completed = makeActivity({
id: EventId.make("completion"),
kind: "tool.completed",
summary: "Command completed",
createdAt: "2026-04-01T00:00:02.000Z",
payload: { toolCallId: "call", status: "completed", detail: "Done" },
});
const started = makeActivity({
...completed,
id: EventId.make("start"),
kind: "tool.updated",
createdAt: "2026-04-01T00:00:01.000Z",
payload: {
toolCallId: "call",
data: { command: "original command" },
status: "inProgress",
},
});
const row = (activities: ReadonlyArray<OrchestrationThreadActivity>) =>
buildThreadFeed({ messages: [], activities }).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
)[0]!;
const original = row([started, completed]);
expect(original.workEntry.command).toBe("original command");
const replaced = row([
{
...started,
payload: {
toolCallId: "call",
data: { command: "replacement command" },
status: "inProgress",
},
},
completed,
]);
expect(replaced.workEntry.command).toBe("replacement command");
expect(row([completed]).workEntry.command).toBeUndefined();
expect(row([started, completed]).workEntry.command).toBe("original command");
});

it("reuses unchanged feed and presentation rows during an assistant text update", () => {
const completedTurnId = TurnId.make("completed-turn");
const activeTurnId = TurnId.make("active-turn");
Expand Down
31 changes: 29 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ const activityEntriesCache = new WeakMap<
ReadonlyArray<OrchestrationThreadActivity>,
ReadonlyArray<Extract<RawThreadFeedEntry, { readonly type: "activity" }>>
>();
// Activity arrays change on every tool update; their unchanged rows must not
// repeatedly parse old output or invalidate the rendered work log.
const derivedActivityCache = new WeakMap<OrchestrationThreadActivity, DerivedWorkLogEntry>();
const mergedWorkEntriesCache = new WeakMap<
DerivedWorkLogEntry,
WeakMap<DerivedWorkLogEntry, DerivedWorkLogEntry>
>();
const activityEntryCache = new WeakMap<
DerivedWorkLogEntry,
Extract<RawThreadFeedEntry, { readonly type: "activity" }>
>();
const messageEntriesCache = new WeakMap<
OrchestrationThread["messages"][number],
Extract<RawThreadFeedEntry, { readonly type: "message" }>
Expand Down Expand Up @@ -476,6 +487,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);

function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
const cached = derivedActivityCache.get(activity);
if (cached) return cached;
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
Expand Down Expand Up @@ -630,6 +643,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (collapseKey) {
entry.collapseKey = collapseKey;
}
derivedActivityCache.set(activity, entry);
return entry;
}

Expand Down Expand Up @@ -867,6 +881,9 @@ function mergeDerivedWorkLogEntries(
previous: DerivedWorkLogEntry,
next: DerivedWorkLogEntry,
): DerivedWorkLogEntry {
let byPrevious = mergedWorkEntriesCache.get(next);
const cached = byPrevious?.get(previous);
if (cached) return cached;
const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
const detail = next.detail ?? previous.detail;
const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath;
Expand All @@ -882,7 +899,7 @@ function mergeDerivedWorkLogEntries(
const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
const toolCallId = next.toolCallId ?? previous.toolCallId;
const toolData = next.toolData ?? previous.toolData;
return {
const merged = {
...previous,
...next,
id: previous.id,
Expand All @@ -903,6 +920,12 @@ function mergeDerivedWorkLogEntries(
...(toolCallId ? { toolCallId } : {}),
...(toolData !== undefined ? { toolData } : {}),
};
if (byPrevious === undefined) {
byPrevious = new WeakMap();
mergedWorkEntriesCache.set(next, byPrevious);
}
byPrevious.set(previous, merged);
return merged;
}

function mergeChangedFiles(
Expand Down Expand Up @@ -2459,6 +2482,8 @@ function getThreadFeedActivityEntries(activities: ReadonlyArray<OrchestrationThr
function toThreadFeedActivityEntry(
entry: DerivedWorkLogEntry,
): Extract<RawThreadFeedEntry, { readonly type: "activity" }> {
const cached = activityEntryCache.get(entry);
if (cached) return cached;
const summary = workEntryHeading(entry);
const detail = workEntryPreview(entry);
const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
Expand All @@ -2478,7 +2503,7 @@ function toThreadFeedActivityEntry(
})
.join("\n");
});
return {
const result: Extract<RawThreadFeedEntry, { readonly type: "activity" }> = {
type: "activity",
id: entry.id,
createdAt: entry.createdAt,
Expand All @@ -2499,4 +2524,6 @@ function toThreadFeedActivityEntry(
workEntry: entry,
},
};
activityEntryCache.set(entry, result);
return result;
}
Loading