Skip to content
1 change: 1 addition & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ module.exports = require('eslint-config-sukka').sukka(
// Workspace tests belong to their source tsconfig or a tests/tsconfig.json referenced by
// the root solution; listing them here too would breach typescript-eslint's 8-file cap.
'vitest.config.ts',
'vitest.setup.ts',
],
},
},
Expand Down
45 changes: 45 additions & 0 deletions packages/client/workbench/src/mock/data/showcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,51 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho
},
],
},
{
// The gutter/context case: a real multi-hunk patch, so the card shows absolute line
// numbers, surrounding rows, and a `@@` separator between hunks. Every other diff fixture
// here is a 1-3 line oldText/newText pair, which exercises none of that.
toolCallId: 'mock-tool-edit-multi-hunk',
title: 'Rewire the conversation pipeline',
kind: 'edit',
status: 'completed',
content: [
{
type: 'diff',
change: 'modify',
path: 'packages/client/core/src/conversation.ts',
patch: {
format: 'git_patch',
text: [
'@@ -12,7 +12,8 @@',
' ',
' const MAX_ITEMS = 500;',
' ',
'-export function foldTurn(items: ConversationItem[]): Turn {',
'+/** Folds one turn, dropping items past the cap. */',
'+export function foldTurn(items: readonly ConversationItem[]): Turn {',
' const turn = createTurn();',
' for (const item of items) {',
' turn.push(item);',
'@@ -84,9 +85,11 @@',
' return turn;',
' }',
' ',
'-function createTurn(): Turn {',
'- return { items: [], tokens: 0 };',
'+function createTurn(seed?: Partial<Turn>): Turn {',
'+ return { items: [], tokens: 0, ...seed };',
' }',
' ',
' export function isBoundary(item: ConversationItem): boolean {',
'- return item.kind === "user";',
'+ return item.kind === "user" || item.kind === "compaction";',
' }',
].join('\n'),
},
},
],
},
{
toolCallId: 'mock-tool-write-plan',
title: 'Write PLAN.md',
Expand Down
17 changes: 16 additions & 1 deletion packages/foundation/schema/src/model/__tests__/tool-call.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { ToolCallContentSchema } from '../tool-call';
import { ToolCallContentSchema, unifiedPatchText } from '../tool-call';

describe('ToolCallContentSchema diff', () => {
it('keeps legacy oldText/newText diffs readable', () => {
Expand All @@ -25,3 +25,18 @@ describe('ToolCallContentSchema diff', () => {
expect(ToolCallContentSchema.safeParse({ type: 'diff', ...diff }).success).toBe(true);
});
});

describe('unifiedPatchText', () => {
it('emits a header per hunk followed by that hunk body', () => {
expect(
unifiedPatchText([
{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' same', '-before'] },
{ oldStart: 40, oldLines: 1, newStart: 40, newLines: 2, lines: ['+added', ' tail'] },
]),
).toBe('@@ -1,2 +1,2 @@\n same\n-before\n@@ -40,1 +40,2 @@\n+added\n tail');
});

it('renders no hunks as empty text', () => {
expect(unifiedPatchText([])).toBe('');
});
});
22 changes: 22 additions & 0 deletions packages/foundation/schema/src/model/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ export const ToolDiffPatchSchema = z.object({
});
export type ToolDiffPatch = z.infer<typeof ToolDiffPatchSchema>;

/** One `@@` group of a unified diff. Structurally what both jsdiff's `structuredPatch` and the
* claude SDK's `structuredPatch` hand back, so one formatter serves both. */
export interface UnifiedDiffHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
/** Body lines, each already carrying its ` ` / `-` / `+` prefix. */
lines: readonly string[];
}

/** Hunks → `ToolDiffPatch.text`. Only the text crosses the wire, so the hunk shape stays a plain
* interface rather than a schema. */
export function unifiedPatchText(hunks: readonly UnifiedDiffHunk[]): string {
return hunks
.flatMap((hunk) => [
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
...hunk.lines,
])
.join('\n');
}

/** Tool-call output content: a wrapped ContentBlock, a file diff, or a live terminal reference. */
export const ToolCallContentSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('content'), content: ContentBlockSchema }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,56 @@ describe('buildClaudeTranscriptSupplement', () => {
// Error results carry a plain string — nothing to project.
resultRow('r3', ['toolu_4'], 'String to replace not found'),
]);
expect([...supplement.toolUseResults.entries()]).toEqual([
['toolu_1', { code: 200, codeText: 'OK' }],
expect([...supplement.toolUseResults]).toEqual([['toolu_1', { code: 200, codeText: 'OK' }]]);

// An Edit result keys both projections off the one raw field: the envelope keeps the small
// scalars (dropping the bulk `originalFile`), and the patch survives the same filter, which
// would otherwise drop `structuredPatch` for being an array.
const edits = buildClaudeTranscriptSupplement([
resultRow('r1', ['toolu_1'], {
filePath: 'src/a.ts',
oldString: 'a',
newString: 'b',
originalFile: 'x'.repeat(400),
userModified: false,
replaceAll: true,
structuredPatch: [
{ oldStart: 12, oldLines: 3, newStart: 12, newLines: 3, lines: [' ctx', '-a', '+b'] },
],
}),
resultRow('r2', ['toolu_2', 'toolu_3'], {
filePath: 'src/b.ts',
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a'] }],
}),
]);
expect([...edits.toolUseResults]).toEqual([
[
'toolu_1',
{
filePath: 'src/a.ts',
oldString: 'a',
newString: 'b',
userModified: false,
replaceAll: true,
},
],
]);
expect([...edits.toolUsePatches]).toEqual([
[
'toolu_1',
[
{
type: 'diff',
change: 'modify',
path: 'src/a.ts',
oldText: 'a',
newText: 'b',
patch: { format: 'git_patch', text: '@@ -12,3 +12,3 @@\n ctx\n-a\n+b' },
},
],
],
]);
});
});
Expand All @@ -256,7 +304,13 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => {
}

function supplementOf(partial: Partial<ClaudeTranscriptSupplement>): ClaudeTranscriptSupplement {
return { records: new Map(), droppedRows: [], toolUseResults: new Map(), ...partial };
return {
records: new Map(),
droppedRows: [],
toolUseResults: new Map(),
toolUsePatches: new Map(),
...partial,
};
}

class HistoryClaude extends ClaudeCodeAdapter {
Expand Down Expand Up @@ -376,6 +430,41 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => {
}
});

it('replays an Edit settle with the recovered patch instead of the announce fragment', async () => {
const patch = {
type: 'diff' as const,
change: 'modify' as const,
path: 'src/a.ts',
oldText: 'a',
newText: 'b',
patch: { format: 'git_patch' as const, text: '@@ -12,3 +12,3 @@\n ctx\n-a\n+b' },
};
const adapter = new HistoryClaude(
[
row('assistant', 'a0', [
{
type: 'tool_use',
id: 'toolu_1',
name: 'Edit',
input: { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' },
},
]),
row('user', 'u0', [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'updated' }]),
],
supplementOf({ toolUsePatches: new Map([['toolu_1', [patch]]]) }),
);
const result = await adapter.readHistory({ historyId: asHistoryId(SESSION) });
const settle = result.events.at(-1)?.event;
expect(settle?.type).toBe('tool-call');
if (settle?.type === 'tool-call') {
// Superseded, not stacked — one diff, matching what the live settle emits.
expect(settle.toolCall.content).toEqual([
patch,
{ type: 'content', content: { type: 'text', text: 'updated' } },
]);
}
});

it('reads the supplement on every page but splices dropped rows only into the first', async () => {
const adapter = new HistoryClaude(
[row('user', 'u0', [{ type: 'text', text: 'hello' }])],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { SDKMessage, SessionMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AgentEvent, ToolCall } from '@linkcode/schema';
import { nullthrow } from 'foxts/guard';
import { describe, expect, it } from 'vitest';
import { asHistoryId } from '../history-util';
import { ClaudeCodeAdapter } from '../native/claude-code';
import { ClaudeCodeAdapter, editResultDiffContent } from '../native/claude-code';

/**
* Subagent (Task tool) routing: every subagent frame carries `parent_tool_use_id`. The adapter
Expand Down Expand Up @@ -388,4 +389,71 @@ describe('ClaudeCodeAdapter readHistory subagent splice', () => {
'agent-message',
]);
});

it('replays a subagent Edit with the recovered patch, matching the live settle', async () => {
const EDIT_ID = 'toolu_sub_edit';
const input = { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' };
const toolUseResult = {
filePath: 'src/a.ts',
oldString: 'a',
newString: 'b',
structuredPatch: [
{ oldStart: 12, oldLines: 3, newStart: 12, newLines: 3, lines: [' ctx', '-a', '+b'] },
],
};

// Live: the subagent's settle frame carries the structured result, so `handleUser` upgrades the
// announce fragment in place.
const live = harness();
live.feed({
type: 'assistant',
parent_tool_use_id: TASK_ID,
message: { content: [{ type: 'tool_use', id: EDIT_ID, name: 'Edit', input }] },
});
live.feed({
type: 'user',
parent_tool_use_id: TASK_ID,
message: { content: [{ type: 'tool_result', tool_use_id: EDIT_ID, content: 'updated' }] },
tool_use_result: toolUseResult,
});
const liveSettle = live
.tools()
.filter((t) => t.toolCallId === EDIT_ID)
.at(-1);

// History: `getSubagentMessages` strips `tool_use_result`, so the patch has to come from the
// subagent's own raw transcript instead.
class SubagentPatchClaude extends HistoryClaude {
protected override readSubagentPatches(): Promise<ReadonlyMap<string, ToolCall['content']>> {
return Promise.resolve(
new Map([[EDIT_ID, nullthrow(editResultDiffContent(toolUseResult))]]),
);
}
}
const adapter = new SubagentPatchClaude(
fakeSdk({
messages: mainMessages,
subagents: {
agent1: [
subRow('assistant', 's0', [{ type: 'tool_use', id: EDIT_ID, name: 'Edit', input }]),
subRow('user', 's1', [
{ type: 'tool_result', tool_use_id: EDIT_ID, content: 'updated' },
]),
],
},
}),
);
const result = await adapter.readHistory({ historyId: asHistoryId(SESSION) });
const replayedSettle = result.events.filter((e) => e.itemId === EDIT_ID).at(-1)?.event;

expect(replayedSettle?.type).toBe('tool-call');
if (replayedSettle?.type !== 'tool-call') return;
// The patch supersedes the announce fragment rather than stacking beside it, and the replayed
// content matches what the live path emitted.
expect(replayedSettle.toolCall.content).toEqual(liveSettle?.content);
expect(replayedSettle.toolCall.content[0]).toMatchObject({
type: 'diff',
patch: { format: 'git_patch', text: '@@ -12,3 +12,3 @@\n ctx\n-a\n+b' },
});
});
});
Loading