@bytespell/amux 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +11 -0
- package/CLAUDE.md +104 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +118 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +135 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/{lib/mentions.d.ts → message-parser.d.ts} +3 -5
- package/dist/message-parser.d.ts.map +1 -0
- package/dist/message-parser.js +45 -0
- package/dist/message-parser.js.map +1 -0
- package/dist/message-parser.test.d.ts +2 -0
- package/dist/message-parser.test.d.ts.map +1 -0
- package/dist/message-parser.test.js +188 -0
- package/dist/message-parser.test.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +356 -0
- package/dist/server.js.map +1 -0
- package/dist/session-updates.d.ts +26 -0
- package/dist/session-updates.d.ts.map +1 -0
- package/dist/session-updates.js +68 -0
- package/dist/session-updates.js.map +1 -0
- package/dist/session-updates.test.d.ts +2 -0
- package/dist/session-updates.test.d.ts.map +1 -0
- package/dist/session-updates.test.js +223 -0
- package/dist/session-updates.test.js.map +1 -0
- package/dist/session.d.ts +208 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +580 -0
- package/dist/session.js.map +1 -0
- package/dist/state.d.ts +74 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +250 -0
- package/dist/state.js.map +1 -0
- package/dist/terminal.d.ts +47 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +137 -0
- package/dist/terminal.js.map +1 -0
- package/dist/types.d.ts +64 -2
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -31
- package/dist/types.js.map +1 -1
- package/dist/ws-adapter.d.ts +39 -0
- package/dist/ws-adapter.d.ts.map +1 -0
- package/dist/ws-adapter.js +198 -0
- package/dist/ws-adapter.js.map +1 -0
- package/package.json +47 -24
- package/src/client.ts +162 -0
- package/src/index.ts +66 -0
- package/src/message-parser.test.ts +207 -0
- package/src/message-parser.ts +54 -0
- package/src/session-updates.test.ts +265 -0
- package/src/session-updates.ts +87 -0
- package/src/session.ts +737 -0
- package/src/state.ts +287 -0
- package/src/terminal.ts +164 -0
- package/src/types.ts +88 -0
- package/src/ws-adapter.ts +245 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +7 -0
- package/dist/chunk-5IPYOXBE.js +0 -32
- package/dist/chunk-5IPYOXBE.js.map +0 -1
- package/dist/chunk-C73RKCTS.js +0 -36
- package/dist/chunk-C73RKCTS.js.map +0 -1
- package/dist/chunk-VVXT4HQM.js +0 -779
- package/dist/chunk-VVXT4HQM.js.map +0 -1
- package/dist/lib/logger.d.ts +0 -24
- package/dist/lib/logger.js +0 -17
- package/dist/lib/logger.js.map +0 -1
- package/dist/lib/mentions.js +0 -7
- package/dist/lib/mentions.js.map +0 -1
- package/dist/streams/backends/index.d.ts +0 -88
- package/dist/streams/backends/index.js +0 -13
- package/dist/streams/backends/index.js.map +0 -1
- package/dist/streams/manager.d.ts +0 -55
- package/dist/streams/manager.js +0 -248
- package/dist/streams/manager.js.map +0 -1
- package/dist/types-DCRtrjjj.d.ts +0 -192
- package/scripts/fix-pty.cjs +0 -21
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseMessageToContentBlocks } from './message-parser.js';
|
|
3
|
+
|
|
4
|
+
describe('parseMessageToContentBlocks', () => {
|
|
5
|
+
const workingDir = '/home/user/project';
|
|
6
|
+
|
|
7
|
+
describe('plain text messages', () => {
|
|
8
|
+
it('returns single text block for plain text', () => {
|
|
9
|
+
const result = parseMessageToContentBlocks('hello world', workingDir);
|
|
10
|
+
expect(result).toEqual([{ type: 'text', text: 'hello world' }]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns single text block for empty message', () => {
|
|
14
|
+
const result = parseMessageToContentBlocks('', workingDir);
|
|
15
|
+
expect(result).toEqual([{ type: 'text', text: '' }]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns single text block for whitespace-only message', () => {
|
|
19
|
+
const result = parseMessageToContentBlocks(' ', workingDir);
|
|
20
|
+
expect(result).toEqual([{ type: 'text', text: ' ' }]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('preserves multi-line text', () => {
|
|
24
|
+
const result = parseMessageToContentBlocks('line one\nline two', workingDir);
|
|
25
|
+
expect(result).toEqual([{ type: 'text', text: 'line one\nline two' }]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('single @mentions', () => {
|
|
30
|
+
it('parses single @mention as resource_link', () => {
|
|
31
|
+
const result = parseMessageToContentBlocks('@src/foo.ts', workingDir);
|
|
32
|
+
expect(result).toEqual([
|
|
33
|
+
{
|
|
34
|
+
type: 'resource_link',
|
|
35
|
+
uri: 'file:///home/user/project/src/foo.ts',
|
|
36
|
+
name: 'src/foo.ts',
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('parses relative path with ./', () => {
|
|
42
|
+
const result = parseMessageToContentBlocks('@./local.ts', workingDir);
|
|
43
|
+
expect(result).toEqual([
|
|
44
|
+
{
|
|
45
|
+
type: 'resource_link',
|
|
46
|
+
uri: 'file:///home/user/project/local.ts',
|
|
47
|
+
name: './local.ts',
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('parses relative path with ../', () => {
|
|
53
|
+
const result = parseMessageToContentBlocks('@../parent/file.ts', workingDir);
|
|
54
|
+
expect(result).toEqual([
|
|
55
|
+
{
|
|
56
|
+
type: 'resource_link',
|
|
57
|
+
uri: 'file:///home/user/parent/file.ts',
|
|
58
|
+
name: '../parent/file.ts',
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles file with dashes in name', () => {
|
|
64
|
+
const result = parseMessageToContentBlocks('@src/my-file.ts', workingDir);
|
|
65
|
+
expect(result).toEqual([
|
|
66
|
+
{
|
|
67
|
+
type: 'resource_link',
|
|
68
|
+
uri: 'file:///home/user/project/src/my-file.ts',
|
|
69
|
+
name: 'src/my-file.ts',
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles deeply nested paths', () => {
|
|
75
|
+
const result = parseMessageToContentBlocks('@src/a/b/c/d.ts', workingDir);
|
|
76
|
+
expect(result).toEqual([
|
|
77
|
+
{
|
|
78
|
+
type: 'resource_link',
|
|
79
|
+
uri: 'file:///home/user/project/src/a/b/c/d.ts',
|
|
80
|
+
name: 'src/a/b/c/d.ts',
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('mixed text and @mentions', () => {
|
|
87
|
+
it('parses text before and after mention', () => {
|
|
88
|
+
const result = parseMessageToContentBlocks('check @src/foo.ts for bugs', workingDir);
|
|
89
|
+
expect(result).toEqual([
|
|
90
|
+
{ type: 'text', text: 'check ' },
|
|
91
|
+
{
|
|
92
|
+
type: 'resource_link',
|
|
93
|
+
uri: 'file:///home/user/project/src/foo.ts',
|
|
94
|
+
name: 'src/foo.ts',
|
|
95
|
+
},
|
|
96
|
+
{ type: 'text', text: ' for bugs' },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('parses text only before mention', () => {
|
|
101
|
+
const result = parseMessageToContentBlocks('look at @src/foo.ts', workingDir);
|
|
102
|
+
expect(result).toEqual([
|
|
103
|
+
{ type: 'text', text: 'look at ' },
|
|
104
|
+
{
|
|
105
|
+
type: 'resource_link',
|
|
106
|
+
uri: 'file:///home/user/project/src/foo.ts',
|
|
107
|
+
name: 'src/foo.ts',
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('parses text only after mention', () => {
|
|
113
|
+
const result = parseMessageToContentBlocks('@src/foo.ts has a bug', workingDir);
|
|
114
|
+
expect(result).toEqual([
|
|
115
|
+
{
|
|
116
|
+
type: 'resource_link',
|
|
117
|
+
uri: 'file:///home/user/project/src/foo.ts',
|
|
118
|
+
name: 'src/foo.ts',
|
|
119
|
+
},
|
|
120
|
+
{ type: 'text', text: ' has a bug' },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('multiple @mentions', () => {
|
|
126
|
+
it('parses multiple mentions with text between', () => {
|
|
127
|
+
const result = parseMessageToContentBlocks('@src/a.ts and @src/b.ts', workingDir);
|
|
128
|
+
expect(result).toEqual([
|
|
129
|
+
{
|
|
130
|
+
type: 'resource_link',
|
|
131
|
+
uri: 'file:///home/user/project/src/a.ts',
|
|
132
|
+
name: 'src/a.ts',
|
|
133
|
+
},
|
|
134
|
+
{ type: 'text', text: ' and ' },
|
|
135
|
+
{
|
|
136
|
+
type: 'resource_link',
|
|
137
|
+
uri: 'file:///home/user/project/src/b.ts',
|
|
138
|
+
name: 'src/b.ts',
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('parses consecutive mentions without text between', () => {
|
|
144
|
+
const result = parseMessageToContentBlocks('@a.ts @b.ts', workingDir);
|
|
145
|
+
// The space between becomes whitespace-only and gets skipped
|
|
146
|
+
expect(result).toEqual([
|
|
147
|
+
{
|
|
148
|
+
type: 'resource_link',
|
|
149
|
+
uri: 'file:///home/user/project/a.ts',
|
|
150
|
+
name: 'a.ts',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: 'resource_link',
|
|
154
|
+
uri: 'file:///home/user/project/b.ts',
|
|
155
|
+
name: 'b.ts',
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('edge cases', () => {
|
|
162
|
+
it('handles email-like patterns (should not match)', () => {
|
|
163
|
+
// Email addresses should be matched by regex, which may not be desired
|
|
164
|
+
// This test documents current behavior
|
|
165
|
+
const result = parseMessageToContentBlocks('email user@example.com', workingDir);
|
|
166
|
+
// The regex matches @example.com as a mention
|
|
167
|
+
expect(result[0]).toEqual({ type: 'text', text: 'email user' });
|
|
168
|
+
expect(result[1]).toHaveProperty('type', 'resource_link');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('handles @ at end of string without path', () => {
|
|
172
|
+
const result = parseMessageToContentBlocks('just an @', workingDir);
|
|
173
|
+
expect(result).toEqual([{ type: 'text', text: 'just an @' }]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('handles multiple @ signs', () => {
|
|
177
|
+
const result = parseMessageToContentBlocks('@@foo.ts', workingDir);
|
|
178
|
+
// First @ doesn't match (followed by @), second @ matches
|
|
179
|
+
expect(result).toHaveLength(2);
|
|
180
|
+
expect(result[0]).toEqual({ type: 'text', text: '@' });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('working directory resolution', () => {
|
|
185
|
+
it('uses provided working directory for absolute path resolution', () => {
|
|
186
|
+
const result = parseMessageToContentBlocks('@file.ts', '/custom/dir');
|
|
187
|
+
expect(result).toEqual([
|
|
188
|
+
{
|
|
189
|
+
type: 'resource_link',
|
|
190
|
+
uri: 'file:///custom/dir/file.ts',
|
|
191
|
+
name: 'file.ts',
|
|
192
|
+
},
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('resolves relative paths correctly from working directory', () => {
|
|
197
|
+
const result = parseMessageToContentBlocks('@../sibling/file.ts', '/home/user/project');
|
|
198
|
+
expect(result).toEqual([
|
|
199
|
+
{
|
|
200
|
+
type: 'resource_link',
|
|
201
|
+
uri: 'file:///home/user/sibling/file.ts',
|
|
202
|
+
name: '../sibling/file.ts',
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse message text with @mentions into ContentBlock array.
|
|
6
|
+
* @mentions become resource_link blocks, other text becomes text blocks.
|
|
7
|
+
*
|
|
8
|
+
* Examples:
|
|
9
|
+
* - "hello world" → [{ type: 'text', text: 'hello world' }]
|
|
10
|
+
* - "@src/foo.ts" → [{ type: 'resource_link', uri: 'file://...', name: 'src/foo.ts' }]
|
|
11
|
+
* - "check @src/foo.ts for bugs" → text + resource_link + text
|
|
12
|
+
*/
|
|
13
|
+
export function parseMessageToContentBlocks(
|
|
14
|
+
message: string,
|
|
15
|
+
workingDir: string
|
|
16
|
+
): acp.ContentBlock[] {
|
|
17
|
+
const blocks: acp.ContentBlock[] = [];
|
|
18
|
+
// Match @path/to/file.ts, @./local.ts, @../parent/file.ts
|
|
19
|
+
// Allows leading ./ or ../ followed by path characters
|
|
20
|
+
const mentionRegex = /@(\.{0,2}[\w\/\.\-]+)/g;
|
|
21
|
+
|
|
22
|
+
let lastIndex = 0;
|
|
23
|
+
for (const match of message.matchAll(mentionRegex)) {
|
|
24
|
+
// Add text before mention
|
|
25
|
+
if (match.index! > lastIndex) {
|
|
26
|
+
const text = message.slice(lastIndex, match.index);
|
|
27
|
+
if (text.trim()) {
|
|
28
|
+
blocks.push({ type: 'text', text });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Add resource_link for mention
|
|
33
|
+
const relativePath = match[1]!;
|
|
34
|
+
const absolutePath = path.resolve(workingDir, relativePath);
|
|
35
|
+
blocks.push({
|
|
36
|
+
type: 'resource_link',
|
|
37
|
+
uri: `file://${absolutePath}`,
|
|
38
|
+
name: relativePath,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
lastIndex = match.index! + match[0].length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Add remaining text
|
|
45
|
+
if (lastIndex < message.length) {
|
|
46
|
+
const text = message.slice(lastIndex);
|
|
47
|
+
if (text.trim()) {
|
|
48
|
+
blocks.push({ type: 'text', text });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// If no mentions found, return single text block
|
|
53
|
+
return blocks.length > 0 ? blocks : [{ type: 'text', text: message }];
|
|
54
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isToolCallUpdate,
|
|
4
|
+
isToolCallUpdateMessage,
|
|
5
|
+
normalizeSessionUpdate,
|
|
6
|
+
} from './session-updates.js';
|
|
7
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
8
|
+
|
|
9
|
+
describe('isToolCallUpdate', () => {
|
|
10
|
+
it('returns true for tool_call updates', () => {
|
|
11
|
+
const update = {
|
|
12
|
+
sessionUpdate: 'tool_call',
|
|
13
|
+
id: 'test',
|
|
14
|
+
title: 'Test',
|
|
15
|
+
status: 'pending',
|
|
16
|
+
} as acp.SessionUpdate;
|
|
17
|
+
expect(isToolCallUpdate(update)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns false for other update types', () => {
|
|
21
|
+
const update = {
|
|
22
|
+
sessionUpdate: 'agent_message_chunk',
|
|
23
|
+
content: { type: 'text', text: 'hello' },
|
|
24
|
+
} as acp.SessionUpdate;
|
|
25
|
+
expect(isToolCallUpdate(update)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('isToolCallUpdateMessage', () => {
|
|
30
|
+
it('returns true for tool_call_update updates', () => {
|
|
31
|
+
const update = {
|
|
32
|
+
sessionUpdate: 'tool_call_update',
|
|
33
|
+
id: 'test',
|
|
34
|
+
} as acp.SessionUpdate;
|
|
35
|
+
expect(isToolCallUpdateMessage(update)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns false for other update types', () => {
|
|
39
|
+
const update = {
|
|
40
|
+
sessionUpdate: 'tool_call',
|
|
41
|
+
id: 'test',
|
|
42
|
+
title: 'Test',
|
|
43
|
+
status: 'pending',
|
|
44
|
+
} as acp.SessionUpdate;
|
|
45
|
+
expect(isToolCallUpdateMessage(update)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('normalizeSessionUpdate', () => {
|
|
50
|
+
it('passes through non-tool updates unchanged', () => {
|
|
51
|
+
const update: acp.SessionUpdate = {
|
|
52
|
+
sessionUpdate: 'agent_message_chunk',
|
|
53
|
+
content: { type: 'text', text: 'hello' },
|
|
54
|
+
};
|
|
55
|
+
const result = normalizeSessionUpdate(update);
|
|
56
|
+
expect(result).toEqual(update);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('passes through turn_start unchanged', () => {
|
|
60
|
+
const update: acp.SessionUpdate = {
|
|
61
|
+
sessionUpdate: 'turn_start',
|
|
62
|
+
};
|
|
63
|
+
const result = normalizeSessionUpdate(update);
|
|
64
|
+
expect(result).toEqual(update);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('passes through turn_end unchanged', () => {
|
|
68
|
+
const update: acp.SessionUpdate = {
|
|
69
|
+
sessionUpdate: 'turn_end',
|
|
70
|
+
};
|
|
71
|
+
const result = normalizeSessionUpdate(update);
|
|
72
|
+
expect(result).toEqual(update);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes through tool_call without content unchanged', () => {
|
|
76
|
+
const update = {
|
|
77
|
+
sessionUpdate: 'tool_call',
|
|
78
|
+
id: 'test',
|
|
79
|
+
title: 'Test Tool',
|
|
80
|
+
status: 'pending',
|
|
81
|
+
} as acp.SessionUpdate;
|
|
82
|
+
const result = normalizeSessionUpdate(update);
|
|
83
|
+
expect(result).toEqual(update);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('passes through tool_call with non-diff content unchanged', () => {
|
|
87
|
+
const update = {
|
|
88
|
+
sessionUpdate: 'tool_call',
|
|
89
|
+
id: 'test',
|
|
90
|
+
title: 'Test Tool',
|
|
91
|
+
status: 'pending',
|
|
92
|
+
content: [{ type: 'text', text: 'some output' }],
|
|
93
|
+
} as acp.SessionUpdate;
|
|
94
|
+
const result = normalizeSessionUpdate(update);
|
|
95
|
+
expect(result).toEqual(update);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('normalizes tool_call with diff content', () => {
|
|
99
|
+
const update = {
|
|
100
|
+
sessionUpdate: 'tool_call',
|
|
101
|
+
id: 'test',
|
|
102
|
+
title: 'Edit File',
|
|
103
|
+
status: 'completed',
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: 'diff',
|
|
107
|
+
oldText: 'old content',
|
|
108
|
+
newText: 'new content',
|
|
109
|
+
path: '/path/to/file.ts',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
} as acp.SessionUpdate;
|
|
113
|
+
|
|
114
|
+
const result = normalizeSessionUpdate(update);
|
|
115
|
+
|
|
116
|
+
// Should have same structure but with normalized diff
|
|
117
|
+
expect(result.sessionUpdate).toBe('tool_call');
|
|
118
|
+
expect((result as { content?: acp.ToolCallContent[] }).content).toHaveLength(1);
|
|
119
|
+
|
|
120
|
+
const diffContent = (result as { content: acp.ToolCallContent[] }).content[0];
|
|
121
|
+
expect(diffContent.type).toBe('diff');
|
|
122
|
+
|
|
123
|
+
// The newText should now contain unified diff format
|
|
124
|
+
const normalizedDiff = diffContent as { newText: string; oldText: string; path: string };
|
|
125
|
+
expect(normalizedDiff.newText).toContain('Index: /path/to/file.ts');
|
|
126
|
+
expect(normalizedDiff.newText).toContain('---');
|
|
127
|
+
expect(normalizedDiff.newText).toContain('+++');
|
|
128
|
+
expect(normalizedDiff.newText).toContain('-old content');
|
|
129
|
+
expect(normalizedDiff.newText).toContain('+new content');
|
|
130
|
+
expect(normalizedDiff.oldText).toBe('');
|
|
131
|
+
expect(normalizedDiff.path).toBe('/path/to/file.ts');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('normalizes tool_call_update with diff content', () => {
|
|
135
|
+
const update = {
|
|
136
|
+
sessionUpdate: 'tool_call_update',
|
|
137
|
+
id: 'test',
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: 'diff',
|
|
141
|
+
oldText: 'line1\nline2',
|
|
142
|
+
newText: 'line1\nmodified',
|
|
143
|
+
path: 'test.ts',
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
} as acp.SessionUpdate;
|
|
147
|
+
|
|
148
|
+
const result = normalizeSessionUpdate(update);
|
|
149
|
+
const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
|
|
150
|
+
newText: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(diffContent.newText).toContain('-line1');
|
|
154
|
+
expect(diffContent.newText).toContain('-line2');
|
|
155
|
+
expect(diffContent.newText).toContain('+line1');
|
|
156
|
+
expect(diffContent.newText).toContain('+modified');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles empty oldText (new file creation)', () => {
|
|
160
|
+
const update = {
|
|
161
|
+
sessionUpdate: 'tool_call',
|
|
162
|
+
id: 'test',
|
|
163
|
+
title: 'Write File',
|
|
164
|
+
status: 'completed',
|
|
165
|
+
content: [
|
|
166
|
+
{
|
|
167
|
+
type: 'diff',
|
|
168
|
+
oldText: '',
|
|
169
|
+
newText: 'new file content',
|
|
170
|
+
path: 'new-file.ts',
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
} as acp.SessionUpdate;
|
|
174
|
+
|
|
175
|
+
const result = normalizeSessionUpdate(update);
|
|
176
|
+
const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
|
|
177
|
+
newText: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
expect(diffContent.newText).toContain('@@ -0,0 +1,1 @@');
|
|
181
|
+
expect(diffContent.newText).toContain('+new file content');
|
|
182
|
+
// No removed lines in the diff body (lines starting with - followed by content, not headers)
|
|
183
|
+
const lines = diffContent.newText.split('\n');
|
|
184
|
+
const removedLines = lines.filter(l => l.startsWith('-') && !l.startsWith('---'));
|
|
185
|
+
expect(removedLines).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('handles multi-line diffs', () => {
|
|
189
|
+
const update = {
|
|
190
|
+
sessionUpdate: 'tool_call',
|
|
191
|
+
id: 'test',
|
|
192
|
+
title: 'Edit File',
|
|
193
|
+
status: 'completed',
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: 'diff',
|
|
197
|
+
oldText: 'line1\nline2\nline3',
|
|
198
|
+
newText: 'line1\nmodified\nline3\nline4',
|
|
199
|
+
path: 'file.ts',
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
} as acp.SessionUpdate;
|
|
203
|
+
|
|
204
|
+
const result = normalizeSessionUpdate(update);
|
|
205
|
+
const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
|
|
206
|
+
newText: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Should have correct line counts in header
|
|
210
|
+
expect(diffContent.newText).toContain('@@ -1,3 +1,4 @@');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('uses "file" as default path when path is missing', () => {
|
|
214
|
+
const update = {
|
|
215
|
+
sessionUpdate: 'tool_call',
|
|
216
|
+
id: 'test',
|
|
217
|
+
title: 'Edit',
|
|
218
|
+
status: 'completed',
|
|
219
|
+
content: [
|
|
220
|
+
{
|
|
221
|
+
type: 'diff',
|
|
222
|
+
oldText: 'old',
|
|
223
|
+
newText: 'new',
|
|
224
|
+
// path intentionally omitted
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
} as acp.SessionUpdate;
|
|
228
|
+
|
|
229
|
+
const result = normalizeSessionUpdate(update);
|
|
230
|
+
const diffContent = (result as { content: acp.ToolCallContent[] }).content[0] as {
|
|
231
|
+
newText: string;
|
|
232
|
+
path: string;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
expect(diffContent.newText).toContain('Index: file');
|
|
236
|
+
expect(diffContent.path).toBe('file');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('preserves mixed content types in tool_call', () => {
|
|
240
|
+
const update = {
|
|
241
|
+
sessionUpdate: 'tool_call',
|
|
242
|
+
id: 'test',
|
|
243
|
+
title: 'Complex Tool',
|
|
244
|
+
status: 'completed',
|
|
245
|
+
content: [
|
|
246
|
+
{ type: 'text', text: 'Before diff' },
|
|
247
|
+
{
|
|
248
|
+
type: 'diff',
|
|
249
|
+
oldText: 'old',
|
|
250
|
+
newText: 'new',
|
|
251
|
+
path: 'file.ts',
|
|
252
|
+
},
|
|
253
|
+
{ type: 'text', text: 'After diff' },
|
|
254
|
+
],
|
|
255
|
+
} as acp.SessionUpdate;
|
|
256
|
+
|
|
257
|
+
const result = normalizeSessionUpdate(update);
|
|
258
|
+
const content = (result as { content: acp.ToolCallContent[] }).content;
|
|
259
|
+
|
|
260
|
+
expect(content).toHaveLength(3);
|
|
261
|
+
expect(content[0]).toEqual({ type: 'text', text: 'Before diff' });
|
|
262
|
+
expect(content[1]?.type).toBe('diff');
|
|
263
|
+
expect(content[2]).toEqual({ type: 'text', text: 'After diff' });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type guard for tool_call session updates
|
|
5
|
+
*/
|
|
6
|
+
export function isToolCallUpdate(
|
|
7
|
+
update: acp.SessionUpdate
|
|
8
|
+
): update is acp.ToolCall & { sessionUpdate: 'tool_call' } {
|
|
9
|
+
return update.sessionUpdate === 'tool_call';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Type guard for tool_call_update session updates
|
|
14
|
+
*/
|
|
15
|
+
export function isToolCallUpdateMessage(
|
|
16
|
+
update: acp.SessionUpdate
|
|
17
|
+
): update is acp.ToolCallUpdate & { sessionUpdate: 'tool_call_update' } {
|
|
18
|
+
return update.sessionUpdate === 'tool_call_update';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Type guard for diff content items
|
|
23
|
+
*/
|
|
24
|
+
export function isDiffContent(
|
|
25
|
+
item: acp.ToolCallContent
|
|
26
|
+
): item is acp.Diff & { type: 'diff' } {
|
|
27
|
+
return item.type === 'diff';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize ACP session updates.
|
|
32
|
+
* Claude sends Edit/Write diffs as {newText, oldText, path} instead of unified diff.
|
|
33
|
+
* We convert these to standard unified diff format so the UI doesn't need agent-specific logic.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeSessionUpdate(
|
|
36
|
+
update: acp.SessionUpdate
|
|
37
|
+
): acp.SessionUpdate {
|
|
38
|
+
// Only process tool_call and tool_call_update events with content arrays
|
|
39
|
+
if (!isToolCallUpdate(update) && !isToolCallUpdateMessage(update)) {
|
|
40
|
+
return update;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const content = update.content;
|
|
44
|
+
if (!content || !Array.isArray(content)) {
|
|
45
|
+
return update;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check for diff items that need normalization
|
|
49
|
+
const normalizedContent = content.map((item): acp.ToolCallContent => {
|
|
50
|
+
if (!isDiffContent(item)) return item;
|
|
51
|
+
|
|
52
|
+
const diffItem = item;
|
|
53
|
+
const newText = diffItem.newText;
|
|
54
|
+
const oldText = diffItem.oldText;
|
|
55
|
+
const filePath = diffItem.path ?? 'file';
|
|
56
|
+
|
|
57
|
+
// Generate unified diff
|
|
58
|
+
const oldLines = oldText ? oldText.split('\n') : [];
|
|
59
|
+
const newLines = newText.split('\n');
|
|
60
|
+
|
|
61
|
+
let unifiedDiff = `Index: ${filePath}\n`;
|
|
62
|
+
unifiedDiff += '===================================================================\n';
|
|
63
|
+
unifiedDiff += `--- ${filePath}\n`;
|
|
64
|
+
unifiedDiff += `+++ ${filePath}\n`;
|
|
65
|
+
unifiedDiff += `@@ -${oldLines.length > 0 ? 1 : 0},${oldLines.length} +1,${newLines.length} @@\n`;
|
|
66
|
+
|
|
67
|
+
for (const line of oldLines) {
|
|
68
|
+
unifiedDiff += `-${line}\n`;
|
|
69
|
+
}
|
|
70
|
+
for (const line of newLines) {
|
|
71
|
+
unifiedDiff += `+${line}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Return normalized diff item
|
|
75
|
+
return {
|
|
76
|
+
type: 'diff',
|
|
77
|
+
newText: unifiedDiff,
|
|
78
|
+
oldText: '',
|
|
79
|
+
path: filePath,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...update,
|
|
85
|
+
content: normalizedContent,
|
|
86
|
+
};
|
|
87
|
+
}
|