Skip to content

Commit 2b5942c

Browse files
committed
fix(server): preserve tool lifecycle identity
1 parent 033615d commit 2b5942c

4 files changed

Lines changed: 113 additions & 18 deletions

File tree

apps/server/src/orchestration/ActivityPayloadProjection.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
2020
* If slimming ever moves to an allowlist over the whole payload, these
2121
* assertions are the tripwire.
2222
*/
23-
describe("projectActivityPayload agent-field survival", () => {
23+
describe("projectActivityPayload", () => {
2424
it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
2525
const projected = projectActivityPayload(
2626
activity({
@@ -97,6 +97,45 @@ describe("projectActivityPayload agent-field survival", () => {
9797
expect(JSON.stringify(acp.payload).length).toBeLessThan(500);
9898
});
9999

100+
it("normalizes Claude and OpenCode command inputs before slimming provider data", () => {
101+
const claude = projectActivityPayload(
102+
activity({
103+
itemType: "command_execution",
104+
toolCallId: "claude-call-1",
105+
data: {
106+
toolName: "Bash",
107+
input: { command: "vp test run" },
108+
result: { content: "x".repeat(5_000) },
109+
},
110+
}),
111+
);
112+
const openCode = projectActivityPayload(
113+
activity({
114+
itemType: "command_execution",
115+
toolCallId: "opencode-call-1",
116+
data: {
117+
tool: "bash",
118+
state: {
119+
status: "running",
120+
input: { command: "vp lint" },
121+
output: "x".repeat(5_000),
122+
},
123+
},
124+
}),
125+
);
126+
127+
expect(claude.payload).toMatchObject({
128+
toolCallId: "claude-call-1",
129+
data: { command: "vp test run" },
130+
});
131+
expect(openCode.payload).toMatchObject({
132+
toolCallId: "opencode-call-1",
133+
data: { command: "vp lint" },
134+
});
135+
expect(JSON.stringify(claude.payload).length).toBeLessThan(200);
136+
expect(JSON.stringify(openCode.payload).length).toBeLessThan(200);
137+
});
138+
100139
it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
101140
const projected = projectActivityPayload(
102141
activity({

apps/server/src/orchestration/ActivityPayloadProjection.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
125125
return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
126126
}
127127

128+
function projectCommandValue(data: Record<string, unknown>): unknown {
129+
if (data.command !== undefined) {
130+
return data.command;
131+
}
132+
133+
const input = asRecord(data.input);
134+
if (input?.command !== undefined) {
135+
return input.command;
136+
}
137+
138+
const stateInput = asRecord(asRecord(data.state)?.input);
139+
if (stateInput?.command !== undefined) {
140+
return stateInput.command;
141+
}
142+
143+
return undefined;
144+
}
145+
128146
function summarizeToolTextOutput(value: string): string | null {
129147
const lines: string[] = [];
130148
for (const rawLine of value.split(/\r?\n/u)) {
@@ -339,8 +357,9 @@ export function projectActivityPayload(
339357
if (item) {
340358
projectedData.item = item;
341359
}
342-
if ("command" in data) {
343-
projectedData.command = data.command;
360+
const command = projectCommandValue(data);
361+
if (command !== undefined) {
362+
projectedData.command = command;
344363
}
345364

346365
const changedFiles: string[] = [];
@@ -420,18 +439,19 @@ function dropStaleContextWindowActivities(
420439
/**
421440
* Identity both clients use to fold a tool lifecycle row into the call it
422441
* belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and
423-
* mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter
424-
* emits one, otherwise the itemType/title/detail triple. Returns null for rows
425-
* with no identity at all — those never collapse on the client either, so they
426-
* must not be dropped here.
442+
* mobile's `threadActivity`): the runtime item id ingestion stamps as
443+
* `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple.
444+
* Returns null for rows with no identity at all — those never collapse on the
445+
* client either, so they must not be dropped here.
427446
*/
428447
function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
429448
const payload = asRecord(activity.payload);
430449
if (!payload) {
431450
return null;
432451
}
433452

434-
const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);
453+
const toolCallId =
454+
asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId);
435455
if (toolCallId) {
436456
return `id:${toolCallId}`;
437457
}

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2815,11 +2815,16 @@ describe("ProviderRuntimeIngestion", () => {
28152815
createdAt: now,
28162816
threadId: asThreadId("thread-1"),
28172817
turnId: asTurnId("turn-9"),
2818+
itemId: asItemId("tool-call-9"),
28182819
payload: {
28192820
itemType: "command_execution",
2820-
status: "in_progress",
2821-
title: "Read file",
2822-
detail: "/tmp/file.ts",
2821+
status: "inProgress",
2822+
title: "Command run",
2823+
detail: "Bash: vp test run",
2824+
data: {
2825+
toolName: "Bash",
2826+
input: { command: "vp test run" },
2827+
},
28232828
},
28242829
});
28252830

@@ -2834,11 +2839,20 @@ describe("ProviderRuntimeIngestion", () => {
28342839
);
28352840

28362841
expect(thread.session?.status).toBe("ready");
2837-
expect(
2838-
thread.activities.some(
2839-
(activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",
2840-
),
2841-
).toBe(true);
2842+
const activity = thread.activities.find(
2843+
(entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started",
2844+
);
2845+
const payload = activity?.payload as Record<string, unknown> | undefined;
2846+
expect(payload).toMatchObject({
2847+
itemType: "command_execution",
2848+
toolCallId: "tool-call-9",
2849+
status: "inProgress",
2850+
detail: "Bash: vp test run",
2851+
data: {
2852+
toolName: "Bash",
2853+
input: { command: "vp test run" },
2854+
},
2855+
});
28422856
});
28432857

28442858
it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
@@ -2956,6 +2970,8 @@ describe("ProviderRuntimeIngestion", () => {
29562970
expect(toolUpdate?.kind).toBe("tool.updated");
29572971
expect(toolUpdatePayload?.itemType).toBe("command_execution");
29582972
expect(toolUpdatePayload?.status).toBe("in_progress");
2973+
expect(toolUpdatePayload?.toolCallId).toBe("item-p1-tool");
2974+
expect(toolUpdatePayload?.data).toMatchObject({ toolCallId: "item-p1-tool" });
29592975

29602976
const warning = thread.activities.find(
29612977
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning",

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ function truncateDetail(value: string, limit = 180): string {
211211
return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
212212
}
213213

214+
function withToolCallId(data: unknown, toolCallId: string | undefined): unknown {
215+
if (toolCallId === undefined) return data;
216+
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
217+
return { toolCallId, ...data };
218+
}
219+
return {
220+
toolCallId,
221+
...(data !== undefined ? { value: data } : {}),
222+
};
223+
}
224+
214225
function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined {
215226
const trimmed = planMarkdown?.trim();
216227
if (!trimmed) {
@@ -787,6 +798,7 @@ export function runtimeEventToActivities(
787798
if (!isToolLifecycleItemType(event.payload.itemType)) {
788799
return [];
789800
}
801+
const data = withToolCallId(event.payload.data, event.itemId);
790802
// A streaming update's `data` carries the full tool output accumulated
791803
// so far (adapters merge state forward), and a new activity is emitted
792804
// per chunk, so persisting `data` verbatim writes O(N²) bytes per tool
@@ -803,9 +815,10 @@ export function runtimeEventToActivities(
803815
summary: event.payload.title ?? "Tool updated",
804816
payload: {
805817
itemType: event.payload.itemType,
818+
...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
806819
...(event.payload.status ? { status: event.payload.status } : {}),
807820
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
808-
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
821+
...(data !== undefined ? { data } : {}),
809822
...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
810823
...(event.payload.parentToolUseId
811824
? { parentToolUseId: event.payload.parentToolUseId }
@@ -821,6 +834,7 @@ export function runtimeEventToActivities(
821834
if (!isToolLifecycleItemType(event.payload.itemType)) {
822835
return [];
823836
}
837+
const data = withToolCallId(event.payload.data, event.itemId);
824838
return [
825839
{
826840
id: event.eventId,
@@ -830,8 +844,10 @@ export function runtimeEventToActivities(
830844
summary: event.payload.title ?? "Tool",
831845
payload: {
832846
itemType: event.payload.itemType,
847+
...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
848+
...(event.payload.status ? { status: event.payload.status } : {}),
833849
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
834-
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
850+
...(data !== undefined ? { data } : {}),
835851
...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
836852
...(event.payload.parentToolUseId
837853
? { parentToolUseId: event.payload.parentToolUseId }
@@ -847,6 +863,7 @@ export function runtimeEventToActivities(
847863
if (!isToolLifecycleItemType(event.payload.itemType)) {
848864
return [];
849865
}
866+
const data = withToolCallId(event.payload.data, event.itemId);
850867
return [
851868
{
852869
id: event.eventId,
@@ -856,7 +873,10 @@ export function runtimeEventToActivities(
856873
summary: `${event.payload.title ?? "Tool"} started`,
857874
payload: {
858875
itemType: event.payload.itemType,
876+
...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
877+
...(event.payload.status ? { status: event.payload.status } : {}),
859878
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
879+
...(data !== undefined ? { data } : {}),
860880
...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
861881
...(event.payload.parentToolUseId
862882
? { parentToolUseId: event.payload.parentToolUseId }

0 commit comments

Comments
 (0)