Skip to content

Commit 3777cdf

Browse files
committed
fix(server): preserve tool lifecycle identity
1 parent c2db942 commit 3777cdf

4 files changed

Lines changed: 96 additions & 16 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: 23 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,7 @@ 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");
29592974

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

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,7 @@ export function runtimeEventToActivities(
803803
summary: event.payload.title ?? "Tool updated",
804804
payload: {
805805
itemType: event.payload.itemType,
806+
...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
806807
...(event.payload.status ? { status: event.payload.status } : {}),
807808
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
808809
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
@@ -830,6 +831,8 @@ export function runtimeEventToActivities(
830831
summary: event.payload.title ?? "Tool",
831832
payload: {
832833
itemType: event.payload.itemType,
834+
...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
835+
...(event.payload.status ? { status: event.payload.status } : {}),
833836
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
834837
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
835838
...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
@@ -856,7 +859,10 @@ export function runtimeEventToActivities(
856859
summary: `${event.payload.title ?? "Tool"} started`,
857860
payload: {
858861
itemType: event.payload.itemType,
862+
...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
863+
...(event.payload.status ? { status: event.payload.status } : {}),
859864
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
865+
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
860866
...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
861867
...(event.payload.parentToolUseId
862868
? { parentToolUseId: event.payload.parentToolUseId }

0 commit comments

Comments
 (0)