Unified · Split
| @@ -20,7 +20,7 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity · 19 unmodified lines | ||
| 20 | 20 | * If slimming ever moves to an allowlist over the whole payload, these |
| 21 | 21 | * assertions are the tripwire. |
| 22 | 22 | */ |
| 23 | −describe("projectActivityPayload agent-field survival", () => { | |
| 23 | +describe("projectActivityPayload", () => { | |
| 24 | 24 | it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { |
| 25 | 25 | const projected = projectActivityPayload( |
| 26 | 26 | activity({ |
| @@ -97,6 +97,45 @@ describe("projectActivityPayload agent-field survival", () => { · 70 unmodified lines | ||
| 97 | 97 | expect(JSON.stringify(acp.payload).length).toBeLessThan(500); |
| 98 | 98 | }); |
| 99 | 99 | |
| 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 | 139 | it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { |
| 101 | 140 | const projected = projectActivityPayload( |
| 102 | 141 | activity({ |
| @@ -125,6 +125,24 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno · 124 unmodified lines | ||
| 125 | 125 | return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; |
| 126 | 126 | } |
| 127 | 127 | |
| 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 | + | |
| 128 | 146 | function summarizeToolTextOutput(value: string): string | null { |
| 129 | 147 | const lines: string[] = []; |
| 130 | 148 | for (const rawLine of value.split(/\r?\n/u)) { |
| @@ -339,8 +357,9 @@ export function projectActivityPayload( · 208 unmodified lines | ||
| 339 | 357 | if (item) { |
| 340 | 358 | projectedData.item = item; |
| 341 | 359 | } |
| 342 | − if ("command" in data) { | |
| 343 | − projectedData.command = data.command; | |
| 360 | + const command = projectCommandValue(data); | |
| 361 | + if (command !== undefined) { | |
| 362 | + projectedData.command = command; | |
| 344 | 363 | } |
| 345 | 364 | |
| 346 | 365 | const changedFiles: string[] = []; |
| @@ -418,12 +437,10 @@ function dropStaleContextWindowActivities( · 71 unmodified lines | ||
| 418 | 437 | } |
| 419 | 438 | |
| 420 | 439 | /** |
| 421 | − * Identity both clients use to fold a tool lifecycle row into the call it | |
| 422 | − * 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. | |
| 440 | + * Identity used to retain only the newest lifecycle row for each call in a | |
| 441 | + * thread snapshot. Prefer the runtime item id, then the legacy nested id, and | |
| 442 | + * finally the itemType/title/detail triple. Rows without any identity remain | |
| 443 | + * untouched. | |
| 427 | 444 | */ |
| 428 | 445 | function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { |
| 429 | 446 | const payload = asRecord(activity.payload); |
| @@ -431,7 +448,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | · 1 unmodified line | ||
| 431 | 448 | return null; |
| 432 | 449 | } |
| 433 | 450 | |
| 434 | − const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); | |
| 451 | + const toolCallId = | |
| 452 | + asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); | |
| 435 | 453 | if (toolCallId) { |
| 436 | 454 | return `id:${toolCallId}`; |
| 437 | 455 | } |
| @@ -2815,11 +2815,16 @@ describe("ProviderRuntimeIngestion", () => { · 2814 unmodified lines | ||
| 2815 | 2815 | createdAt: now, |
| 2816 | 2816 | threadId: asThreadId("thread-1"), |
| 2817 | 2817 | turnId: asTurnId("turn-9"), |
| 2818 | + itemId: asItemId("tool-call-9"), | |
| 2818 | 2819 | payload: { |
| 2819 | 2820 | 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 | + }, | |
| 2823 | 2828 | }, |
| 2824 | 2829 | }); |
| 2825 | 2830 | |
| @@ -2834,11 +2839,20 @@ describe("ProviderRuntimeIngestion", () => { · 8 unmodified lines | ||
| 2834 | 2839 | ); |
| 2835 | 2840 | |
| 2836 | 2841 | 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 | + }); | |
| 2842 | 2856 | }); |
| 2843 | 2857 | |
| 2844 | 2858 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { |
| @@ -2956,6 +2970,7 @@ describe("ProviderRuntimeIngestion", () => { · 111 unmodified lines | ||
| 2956 | 2970 | expect(toolUpdate?.kind).toBe("tool.updated"); |
| 2957 | 2971 | expect(toolUpdatePayload?.itemType).toBe("command_execution"); |
| 2958 | 2972 | expect(toolUpdatePayload?.status).toBe("in_progress"); |
| 2973 | + expect(toolUpdatePayload?.toolCallId).toBe("item-p1-tool"); | |
| 2959 | 2974 | |
| 2960 | 2975 | const warning = thread.activities.find( |
| 2961 | 2976 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning", |
| @@ -803,6 +803,7 @@ export function runtimeEventToActivities( · 802 unmodified lines | ||
| 803 | 803 | summary: event.payload.title ?? "Tool updated", |
| 804 | 804 | payload: { |
| 805 | 805 | itemType: event.payload.itemType, |
| 806 | + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | |
| 806 | 807 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 807 | 808 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 808 | 809 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| @@ -830,6 +831,8 @@ export function runtimeEventToActivities( · 21 unmodified lines | ||
| 830 | 831 | summary: event.payload.title ?? "Tool", |
| 831 | 832 | payload: { |
| 832 | 833 | itemType: event.payload.itemType, |
| 834 | + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | |
| 835 | + ...(event.payload.status ? { status: event.payload.status } : {}), | |
| 833 | 836 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 834 | 837 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 835 | 838 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| @@ -856,7 +859,10 @@ export function runtimeEventToActivities( · 20 unmodified lines | ||
| 856 | 859 | summary: `${event.payload.title ?? "Tool"} started`, |
| 857 | 860 | payload: { |
| 858 | 861 | itemType: event.payload.itemType, |
| 862 | + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | |
| 863 | + ...(event.payload.status ? { status: event.payload.status } : {}), | |
| 859 | 864 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 865 | + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | |
| 860 | 866 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| 861 | 867 | ...(event.payload.parentToolUseId |
| 862 | 868 | ? { parentToolUseId: event.payload.parentToolUseId } |