authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 21:28:40-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 21:28:40-04:00
logb2e2ccfdb462c45a8de394ff9ff0f58a4a689900
treec47d962e95381a1c22a4d2f8dadcefb1dbf72ea2
parent792a1404f6d25838597f8f31b0af144dc1beee12
signature Signed by PGP key B5690EEEBB952194

fix(server): preserve tool lifecycle identity (#7151)


4 files changed, 96 insertions(+), 18 deletions(-)

apps/server/src/orchestration/ActivityPayloadProjection.test.ts+40-1
...@@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity...@@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
20 * If slimming ever moves to an allowlist over the whole payload, these20 * If slimming ever moves to an allowlist over the whole payload, these
21 * assertions are the tripwire.21 * assertions are the tripwire.
22 */22 */
23describe("projectActivityPayload agent-field survival", () => {23describe("projectActivityPayload", () => {
24 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {24 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
25 const projected = projectActivityPayload(25 const projected = projectActivityPayload(
26 activity({26 activity({
...@@ -97,6 +97,45 @@ describe("projectActivityPayload agent-field survival", () => {...@@ -97,6 +97,45 @@ describe("projectActivityPayload agent-field survival", () => {
97 expect(JSON.stringify(acp.payload).length).toBeLessThan(500);97 expect(JSON.stringify(acp.payload).length).toBeLessThan(500);
98 });98 });
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
100 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {139 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
101 const projected = projectActivityPayload(140 const projected = projectActivityPayload(
102 activity({141 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+27-9
...@@ -125,6 +125,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno...@@ -125,6 +125,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
125 return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;125 return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
126}126}
127127
128function 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
128function summarizeToolTextOutput(value: string): string | null {146function summarizeToolTextOutput(value: string): string | null {
129 const lines: string[] = [];147 const lines: string[] = [];
130 for (const rawLine of value.split(/\r?\n/u)) {148 for (const rawLine of value.split(/\r?\n/u)) {
...@@ -339,8 +357,9 @@ export function projectActivityPayload(...@@ -339,8 +357,9 @@ export function projectActivityPayload(
339 if (item) {357 if (item) {
340 projectedData.item = item;358 projectedData.item = item;
341 }359 }
342 if ("command" in data) {360 const command = projectCommandValue(data);
343 projectedData.command = data.command;361 if (command !== undefined) {
362 projectedData.command = command;
344 }363 }
345364
346 const changedFiles: string[] = [];365 const changedFiles: string[] = [];
...@@ -418,12 +437,10 @@ function dropStaleContextWindowActivities(...@@ -418,12 +437,10 @@ function dropStaleContextWindowActivities(
418}437}
419438
420/**439/**
421 * Identity both clients use to fold a tool lifecycle row into the call it440 * Identity used to retain only the newest lifecycle row for each call in a
422 * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and441 * thread snapshot. Prefer the runtime item id, then the legacy nested id, and
423 * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter442 * finally the itemType/title/detail triple. Rows without any identity remain
424 * emits one, otherwise the itemType/title/detail triple. Returns null for rows443 * untouched.
425 * with no identity at all — those never collapse on the client either, so they
426 * must not be dropped here.
427 */444 */
428function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {445function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null {
429 const payload = asRecord(activity.payload);446 const payload = asRecord(activity.payload);
...@@ -431,7 +448,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |...@@ -431,7 +448,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string |
431 return null;448 return null;
432 }449 }
433450
434 const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId);451 const toolCallId =
452 asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId);
435 if (toolCallId) {453 if (toolCallId) {
436 return `id:${toolCallId}`;454 return `id:${toolCallId}`;
437 }455 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+23-8
...@@ -2815,11 +2815,16 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2815,11 +2815,16 @@ describe("ProviderRuntimeIngestion", () => {
2815 createdAt: now,2815 createdAt: now,
2816 threadId: asThreadId("thread-1"),2816 threadId: asThreadId("thread-1"),
2817 turnId: asTurnId("turn-9"),2817 turnId: asTurnId("turn-9"),
2818 itemId: asItemId("tool-call-9"),
2818 payload: {2819 payload: {
2819 itemType: "command_execution",2820 itemType: "command_execution",
2820 status: "in_progress",2821 status: "inProgress",
2821 title: "Read file",2822 title: "Command run",
2822 detail: "/tmp/file.ts",2823 detail: "Bash: vp test run",
2824 data: {
2825 toolName: "Bash",
2826 input: { command: "vp test run" },
2827 },
2823 },2828 },
2824 });2829 });
28252830
...@@ -2834,11 +2839,20 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2834,11 +2839,20 @@ describe("ProviderRuntimeIngestion", () => {
2834 );2839 );
28352840
2836 expect(thread.session?.status).toBe("ready");2841 expect(thread.session?.status).toBe("ready");
2837 expect(2842 const activity = thread.activities.find(
2838 thread.activities.some(2843 (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started",
2839 (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started",2844 );
2840 ),2845 const payload = activity?.payload as Record<string, unknown> | undefined;
2841 ).toBe(true);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 });
2842 });2856 });
28432857
2844 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {2858 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
...@@ -2956,6 +2970,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2956,6 +2970,7 @@ describe("ProviderRuntimeIngestion", () => {
2956 expect(toolUpdate?.kind).toBe("tool.updated");2970 expect(toolUpdate?.kind).toBe("tool.updated");
2957 expect(toolUpdatePayload?.itemType).toBe("command_execution");2971 expect(toolUpdatePayload?.itemType).toBe("command_execution");
2958 expect(toolUpdatePayload?.status).toBe("in_progress");2972 expect(toolUpdatePayload?.status).toBe("in_progress");
2973 expect(toolUpdatePayload?.toolCallId).toBe("item-p1-tool");
29592974
2960 const warning = thread.activities.find(2975 const warning = thread.activities.find(
2961 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning",2976 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning",
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6
...@@ -803,6 +803,7 @@ export function runtimeEventToActivities(...@@ -803,6 +803,7 @@ export function runtimeEventToActivities(
803 summary: event.payload.title ?? "Tool updated",803 summary: event.payload.title ?? "Tool updated",
804 payload: {804 payload: {
805 itemType: event.payload.itemType,805 itemType: event.payload.itemType,
806 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
806 ...(event.payload.status ? { status: event.payload.status } : {}),807 ...(event.payload.status ? { status: event.payload.status } : {}),
807 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),808 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
808 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),809 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
...@@ -830,6 +831,8 @@ export function runtimeEventToActivities(...@@ -830,6 +831,8 @@ export function runtimeEventToActivities(
830 summary: event.payload.title ?? "Tool",831 summary: event.payload.title ?? "Tool",
831 payload: {832 payload: {
832 itemType: event.payload.itemType,833 itemType: event.payload.itemType,
834 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
835 ...(event.payload.status ? { status: event.payload.status } : {}),
833 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),836 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
834 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),837 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
835 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),838 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
...@@ -856,7 +859,10 @@ export function runtimeEventToActivities(...@@ -856,7 +859,10 @@ export function runtimeEventToActivities(
856 summary: `${event.payload.title ?? "Tool"} started`,859 summary: `${event.payload.title ?? "Tool"} started`,
857 payload: {860 payload: {
858 itemType: event.payload.itemType,861 itemType: event.payload.itemType,
862 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
863 ...(event.payload.status ? { status: event.payload.status } : {}),
859 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),864 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
865 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
860 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),866 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
861 ...(event.payload.parentToolUseId867 ...(event.payload.parentToolUseId
862 ? { parentToolUseId: event.payload.parentToolUseId }868 ? { parentToolUseId: event.payload.parentToolUseId }