@ai-devkit/agent-manager 0.5.0 → 0.6.1
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/dist/adapters/AgentAdapter.d.ts +19 -2
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +15 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +94 -6
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +9 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +56 -2
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +34 -4
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/utils/matching.d.ts +1 -1
- package/dist/utils/matching.d.ts.map +1 -1
- package/dist/utils/matching.js +6 -2
- package/dist/utils/matching.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/AgentManager.test.ts +5 -2
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +177 -6
- package/src/__tests__/adapters/CodexAdapter.test.ts +115 -0
- package/src/__tests__/terminal/TtyWriter.test.ts +13 -17
- package/src/__tests__/utils/matching.test.ts +13 -5
- package/src/adapters/AgentAdapter.ts +20 -4
- package/src/adapters/ClaudeCodeAdapter.ts +111 -15
- package/src/adapters/CodexAdapter.ts +59 -3
- package/src/index.ts +1 -1
- package/src/terminal/TtyWriter.ts +34 -4
- package/src/utils/matching.ts +6 -2
|
@@ -168,7 +168,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
168
168
|
// Create session file
|
|
169
169
|
const sessionFile = path.join(projDir, 'session-1.jsonl');
|
|
170
170
|
fs.writeFileSync(sessionFile, [
|
|
171
|
-
JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project',
|
|
171
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', message: { content: 'Investigate failing tests' } }),
|
|
172
172
|
JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
|
|
173
173
|
].join('\n'));
|
|
174
174
|
|
|
@@ -203,7 +203,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
203
203
|
pid: 12345,
|
|
204
204
|
projectPath: '/Users/test/my-project',
|
|
205
205
|
sessionId: 'session-1',
|
|
206
|
-
slug: 'merry-dog',
|
|
207
206
|
});
|
|
208
207
|
expect(agents[0].summary).toContain('Investigate failing tests');
|
|
209
208
|
|
|
@@ -977,12 +976,12 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
977
976
|
});
|
|
978
977
|
|
|
979
978
|
describe('readSession', () => {
|
|
980
|
-
it('should parse session file with timestamps,
|
|
979
|
+
it('should parse session file with timestamps, cwd, and entry type', () => {
|
|
981
980
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
982
981
|
|
|
983
982
|
const filePath = path.join(tmpDir, 'test-session.jsonl');
|
|
984
983
|
const lines = [
|
|
985
|
-
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project'
|
|
984
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project' }),
|
|
986
985
|
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
987
986
|
];
|
|
988
987
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
@@ -991,7 +990,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
991
990
|
expect(session).toMatchObject({
|
|
992
991
|
sessionId: 'test-session',
|
|
993
992
|
projectPath: '/my/project',
|
|
994
|
-
slug: 'happy-dog',
|
|
995
993
|
lastCwd: '/my/project',
|
|
996
994
|
lastEntryType: 'assistant',
|
|
997
995
|
isInterrupted: false,
|
|
@@ -1029,7 +1027,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1029
1027
|
const session = readSession(filePath, '/test');
|
|
1030
1028
|
expect(session).not.toBeNull();
|
|
1031
1029
|
expect(session.lastEntryType).toBeUndefined();
|
|
1032
|
-
expect(session.slug).toBeUndefined();
|
|
1033
1030
|
});
|
|
1034
1031
|
|
|
1035
1032
|
it('should return null for non-existent file', () => {
|
|
@@ -1117,4 +1114,178 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1117
1114
|
});
|
|
1118
1115
|
});
|
|
1119
1116
|
});
|
|
1117
|
+
|
|
1118
|
+
describe('getConversation', () => {
|
|
1119
|
+
let tmpDir: string;
|
|
1120
|
+
|
|
1121
|
+
beforeEach(() => {
|
|
1122
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-conv-'));
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
afterEach(() => {
|
|
1126
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
function writeJsonl(lines: object[]): string {
|
|
1130
|
+
const filePath = path.join(tmpDir, 'session.jsonl');
|
|
1131
|
+
fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n'));
|
|
1132
|
+
return filePath;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
it('should parse user and assistant text messages', () => {
|
|
1136
|
+
const filePath = writeJsonl([
|
|
1137
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } },
|
|
1138
|
+
{ type: 'assistant', timestamp: '2026-03-27T10:00:05Z', message: { content: [{ type: 'text', text: 'Hi there!' }] } },
|
|
1139
|
+
]);
|
|
1140
|
+
|
|
1141
|
+
const messages = adapter.getConversation(filePath);
|
|
1142
|
+
expect(messages).toHaveLength(2);
|
|
1143
|
+
expect(messages[0]).toEqual({ role: 'user', content: 'Hello', timestamp: '2026-03-27T10:00:00Z' });
|
|
1144
|
+
expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi there!', timestamp: '2026-03-27T10:00:05Z' });
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it('should skip metadata entry types', () => {
|
|
1148
|
+
const filePath = writeJsonl([
|
|
1149
|
+
{ type: 'file-history-snapshot', timestamp: '2026-03-27T10:00:00Z', snapshot: {} },
|
|
1150
|
+
{ type: 'last-prompt', timestamp: '2026-03-27T10:00:00Z' },
|
|
1151
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Fix bug' } },
|
|
1152
|
+
]);
|
|
1153
|
+
|
|
1154
|
+
const messages = adapter.getConversation(filePath);
|
|
1155
|
+
expect(messages).toHaveLength(1);
|
|
1156
|
+
expect(messages[0].content).toBe('Fix bug');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('should skip progress and thinking entries', () => {
|
|
1160
|
+
const filePath = writeJsonl([
|
|
1161
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } },
|
|
1162
|
+
{ type: 'progress', timestamp: '2026-03-27T10:00:01Z', data: {} },
|
|
1163
|
+
{ type: 'thinking', timestamp: '2026-03-27T10:00:02Z' },
|
|
1164
|
+
{ type: 'assistant', timestamp: '2026-03-27T10:00:03Z', message: { content: [{ type: 'text', text: 'Done' }] } },
|
|
1165
|
+
]);
|
|
1166
|
+
|
|
1167
|
+
const messages = adapter.getConversation(filePath);
|
|
1168
|
+
expect(messages).toHaveLength(2);
|
|
1169
|
+
expect(messages[0].role).toBe('user');
|
|
1170
|
+
expect(messages[1].role).toBe('assistant');
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('should include system messages', () => {
|
|
1174
|
+
const filePath = writeJsonl([
|
|
1175
|
+
{ type: 'system', timestamp: '2026-03-27T10:00:00Z', message: { content: 'System initialized' } },
|
|
1176
|
+
]);
|
|
1177
|
+
|
|
1178
|
+
const messages = adapter.getConversation(filePath);
|
|
1179
|
+
expect(messages).toHaveLength(1);
|
|
1180
|
+
expect(messages[0]).toEqual({ role: 'system', content: 'System initialized', timestamp: '2026-03-27T10:00:00Z' });
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('should skip tool_use and tool_result blocks in default mode', () => {
|
|
1184
|
+
const filePath = writeJsonl([
|
|
1185
|
+
{
|
|
1186
|
+
type: 'assistant', timestamp: '2026-03-27T10:00:00Z',
|
|
1187
|
+
message: {
|
|
1188
|
+
content: [
|
|
1189
|
+
{ type: 'text', text: 'Let me read the file.' },
|
|
1190
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } },
|
|
1191
|
+
],
|
|
1192
|
+
},
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
type: 'user', timestamp: '2026-03-27T10:00:01Z',
|
|
1196
|
+
message: {
|
|
1197
|
+
content: [
|
|
1198
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' },
|
|
1199
|
+
],
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
]);
|
|
1203
|
+
|
|
1204
|
+
const messages = adapter.getConversation(filePath);
|
|
1205
|
+
expect(messages).toHaveLength(1);
|
|
1206
|
+
expect(messages[0].content).toBe('Let me read the file.');
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it('should include tool_use and tool_result blocks in verbose mode', () => {
|
|
1210
|
+
const filePath = writeJsonl([
|
|
1211
|
+
{
|
|
1212
|
+
type: 'assistant', timestamp: '2026-03-27T10:00:00Z',
|
|
1213
|
+
message: {
|
|
1214
|
+
content: [
|
|
1215
|
+
{ type: 'text', text: 'Let me read the file.' },
|
|
1216
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } },
|
|
1217
|
+
],
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
type: 'user', timestamp: '2026-03-27T10:00:01Z',
|
|
1222
|
+
message: {
|
|
1223
|
+
content: [
|
|
1224
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' },
|
|
1225
|
+
],
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
]);
|
|
1229
|
+
|
|
1230
|
+
const messages = adapter.getConversation(filePath, { verbose: true });
|
|
1231
|
+
expect(messages).toHaveLength(2);
|
|
1232
|
+
expect(messages[0].content).toContain('[Tool: Read]');
|
|
1233
|
+
expect(messages[0].content).toContain('/src/app.ts');
|
|
1234
|
+
expect(messages[1].content).toContain('[Tool Result]');
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it('should handle tool_result errors in verbose mode', () => {
|
|
1238
|
+
const filePath = writeJsonl([
|
|
1239
|
+
{
|
|
1240
|
+
type: 'user', timestamp: '2026-03-27T10:00:00Z',
|
|
1241
|
+
message: {
|
|
1242
|
+
content: [
|
|
1243
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'Something went wrong', is_error: true },
|
|
1244
|
+
],
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
]);
|
|
1248
|
+
|
|
1249
|
+
const messages = adapter.getConversation(filePath, { verbose: true });
|
|
1250
|
+
expect(messages).toHaveLength(1);
|
|
1251
|
+
expect(messages[0].content).toContain('[Tool Error]');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('should handle malformed JSON lines gracefully', () => {
|
|
1255
|
+
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
1256
|
+
fs.writeFileSync(filePath, [
|
|
1257
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } }),
|
|
1258
|
+
'this is not valid json',
|
|
1259
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-27T10:00:01Z', message: { content: [{ type: 'text', text: 'World' }] } }),
|
|
1260
|
+
].join('\n'));
|
|
1261
|
+
|
|
1262
|
+
const messages = adapter.getConversation(filePath);
|
|
1263
|
+
expect(messages).toHaveLength(2);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it('should return empty array for missing file', () => {
|
|
1267
|
+
const messages = adapter.getConversation('/nonexistent/path.jsonl');
|
|
1268
|
+
expect(messages).toEqual([]);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('should return empty array for empty file', () => {
|
|
1272
|
+
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1273
|
+
fs.writeFileSync(filePath, '');
|
|
1274
|
+
|
|
1275
|
+
const messages = adapter.getConversation(filePath);
|
|
1276
|
+
expect(messages).toEqual([]);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('should filter noise messages from user entries', () => {
|
|
1280
|
+
const filePath = writeJsonl([
|
|
1281
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
|
|
1282
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Tool loaded.' } },
|
|
1283
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:02Z', message: { content: 'Real question' } },
|
|
1284
|
+
]);
|
|
1285
|
+
|
|
1286
|
+
const messages = adapter.getConversation(filePath);
|
|
1287
|
+
expect(messages).toHaveLength(1);
|
|
1288
|
+
expect(messages[0].content).toBe('Real question');
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1120
1291
|
});
|
|
@@ -515,4 +515,119 @@ describe('CodexAdapter', () => {
|
|
|
515
515
|
});
|
|
516
516
|
});
|
|
517
517
|
});
|
|
518
|
+
|
|
519
|
+
describe('getConversation', () => {
|
|
520
|
+
let tmpDir: string;
|
|
521
|
+
|
|
522
|
+
beforeEach(() => {
|
|
523
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-conv-'));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
afterEach(() => {
|
|
527
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
function writeJsonl(lines: object[]): string {
|
|
531
|
+
const filePath = path.join(tmpDir, 'session.jsonl');
|
|
532
|
+
fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n'));
|
|
533
|
+
return filePath;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
it('should parse user and agent messages', () => {
|
|
537
|
+
const filePath = writeJsonl([
|
|
538
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
539
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Fix the bug' } },
|
|
540
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:05Z', payload: { type: 'agent_message', message: 'I found the issue' } },
|
|
541
|
+
]);
|
|
542
|
+
|
|
543
|
+
const messages = adapter.getConversation(filePath);
|
|
544
|
+
expect(messages).toHaveLength(2);
|
|
545
|
+
expect(messages[0]).toEqual({ role: 'user', content: 'Fix the bug', timestamp: '2026-03-27T10:00:01Z' });
|
|
546
|
+
expect(messages[1]).toEqual({ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05Z' });
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('should skip session_meta entry', () => {
|
|
550
|
+
const filePath = writeJsonl([
|
|
551
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
552
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } },
|
|
553
|
+
]);
|
|
554
|
+
|
|
555
|
+
const messages = adapter.getConversation(filePath);
|
|
556
|
+
expect(messages).toHaveLength(1);
|
|
557
|
+
expect(messages[0].role).toBe('user');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should map task_complete to assistant role', () => {
|
|
561
|
+
const filePath = writeJsonl([
|
|
562
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
563
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:05Z', payload: { type: 'task_complete', message: 'Task finished successfully' } },
|
|
564
|
+
]);
|
|
565
|
+
|
|
566
|
+
const messages = adapter.getConversation(filePath);
|
|
567
|
+
expect(messages).toHaveLength(1);
|
|
568
|
+
expect(messages[0].role).toBe('assistant');
|
|
569
|
+
expect(messages[0].content).toBe('Task finished successfully');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('should skip non-conversation types in default mode', () => {
|
|
573
|
+
const filePath = writeJsonl([
|
|
574
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
575
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } },
|
|
576
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'exec_command', message: 'Running npm test' } },
|
|
577
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:03Z', payload: { type: 'agent_message', message: 'Done' } },
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
const messages = adapter.getConversation(filePath);
|
|
581
|
+
expect(messages).toHaveLength(2);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should include non-conversation types as system in verbose mode', () => {
|
|
585
|
+
const filePath = writeJsonl([
|
|
586
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
587
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'exec_command', message: 'Running npm test' } },
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
const messages = adapter.getConversation(filePath, { verbose: true });
|
|
591
|
+
expect(messages).toHaveLength(1);
|
|
592
|
+
expect(messages[0].role).toBe('system');
|
|
593
|
+
expect(messages[0].content).toBe('Running npm test');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('should handle malformed JSON lines gracefully', () => {
|
|
597
|
+
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
598
|
+
fs.writeFileSync(filePath, [
|
|
599
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }),
|
|
600
|
+
'invalid json line',
|
|
601
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } }),
|
|
602
|
+
].join('\n'));
|
|
603
|
+
|
|
604
|
+
const messages = adapter.getConversation(filePath);
|
|
605
|
+
expect(messages).toHaveLength(1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should return empty array for missing file', () => {
|
|
609
|
+
const messages = adapter.getConversation('/nonexistent/path.jsonl');
|
|
610
|
+
expect(messages).toEqual([]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('should return empty array for empty file', () => {
|
|
614
|
+
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
615
|
+
fs.writeFileSync(filePath, '');
|
|
616
|
+
|
|
617
|
+
const messages = adapter.getConversation(filePath);
|
|
618
|
+
expect(messages).toEqual([]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('should skip entries with empty payload message', () => {
|
|
622
|
+
const filePath = writeJsonl([
|
|
623
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
624
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: '' } },
|
|
625
|
+
{ type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'agent_message', message: 'Response' } },
|
|
626
|
+
]);
|
|
627
|
+
|
|
628
|
+
const messages = adapter.getConversation(filePath);
|
|
629
|
+
expect(messages).toHaveLength(1);
|
|
630
|
+
expect(messages[0].content).toBe('Response');
|
|
631
|
+
});
|
|
632
|
+
});
|
|
518
633
|
});
|
|
@@ -40,16 +40,22 @@ describe('TtyWriter', () => {
|
|
|
40
40
|
tty: '/dev/ttys030',
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
it('sends message
|
|
43
|
+
it('sends message and Enter as separate tmux send-keys calls', async () => {
|
|
44
44
|
mockExecFileSuccess();
|
|
45
45
|
|
|
46
46
|
await TtyWriter.send(location, 'continue');
|
|
47
47
|
|
|
48
48
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
|
49
49
|
'tmux',
|
|
50
|
-
['send-keys', '-t', 'main:0.1', '
|
|
50
|
+
['send-keys', '-t', 'main:0.1', '-l', 'continue'],
|
|
51
51
|
expect.any(Function),
|
|
52
52
|
);
|
|
53
|
+
expect(mockedExecFile).toHaveBeenCalledWith(
|
|
54
|
+
'tmux',
|
|
55
|
+
['send-keys', '-t', 'main:0.1', 'Enter'],
|
|
56
|
+
expect.any(Function),
|
|
57
|
+
);
|
|
58
|
+
expect(mockedExecFile).toHaveBeenCalledTimes(2);
|
|
53
59
|
});
|
|
54
60
|
|
|
55
61
|
it('throws on tmux failure', async () => {
|
|
@@ -74,9 +80,12 @@ describe('TtyWriter', () => {
|
|
|
74
80
|
|
|
75
81
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
|
76
82
|
'osascript',
|
|
77
|
-
['-e', expect.stringContaining('write text "hello"')],
|
|
83
|
+
['-e', expect.stringContaining('write text "hello" newline no')],
|
|
78
84
|
expect.any(Function),
|
|
79
85
|
);
|
|
86
|
+
const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
|
|
87
|
+
const script = scriptArg[1];
|
|
88
|
+
expect(script).toContain('key code 36');
|
|
80
89
|
});
|
|
81
90
|
|
|
82
91
|
it('escapes special characters in message', async () => {
|
|
@@ -86,7 +95,7 @@ describe('TtyWriter', () => {
|
|
|
86
95
|
|
|
87
96
|
expect(mockedExecFile).toHaveBeenCalledWith(
|
|
88
97
|
'osascript',
|
|
89
|
-
['-e', expect.stringContaining('write text "say \\"hi\\" \\\\ there"')],
|
|
98
|
+
['-e', expect.stringContaining('write text "say \\"hi\\" \\\\ there" newline no')],
|
|
90
99
|
expect.any(Function),
|
|
91
100
|
);
|
|
92
101
|
});
|
|
@@ -113,24 +122,11 @@ describe('TtyWriter', () => {
|
|
|
113
122
|
|
|
114
123
|
const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
|
|
115
124
|
const script = scriptArg[1];
|
|
116
|
-
// Must use keystroke, NOT do script
|
|
117
125
|
expect(script).toContain('keystroke "hello"');
|
|
118
126
|
expect(script).toContain('key code 36');
|
|
119
127
|
expect(script).not.toContain('do script');
|
|
120
128
|
});
|
|
121
129
|
|
|
122
|
-
it('uses execFile to avoid shell injection', async () => {
|
|
123
|
-
mockExecFileSuccess('ok');
|
|
124
|
-
|
|
125
|
-
await TtyWriter.send(location, "don't stop");
|
|
126
|
-
|
|
127
|
-
expect(mockedExecFile).toHaveBeenCalledWith(
|
|
128
|
-
'osascript',
|
|
129
|
-
['-e', expect.any(String)],
|
|
130
|
-
expect.any(Function),
|
|
131
|
-
);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
130
|
it('throws when tab not found', async () => {
|
|
135
131
|
mockExecFileSuccess('not_found');
|
|
136
132
|
|
|
@@ -173,19 +173,27 @@ describe('matchProcessesToSessions', () => {
|
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
describe('generateAgentName', () => {
|
|
176
|
-
it('should return
|
|
177
|
-
expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app
|
|
176
|
+
it('should return lowercase kebab-case name with pid', () => {
|
|
177
|
+
expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app-12345');
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('should handle root path', () => {
|
|
181
|
-
expect(generateAgentName('/', 100)).toBe('unknown
|
|
181
|
+
expect(generateAgentName('/', 100)).toBe('unknown-100');
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it('should handle empty cwd', () => {
|
|
185
|
-
expect(generateAgentName('', 100)).toBe('unknown
|
|
185
|
+
expect(generateAgentName('', 100)).toBe('unknown-100');
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
it('should handle nested paths', () => {
|
|
189
|
-
expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit
|
|
189
|
+
expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit-78070');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should convert spaces and special chars to kebab-case', () => {
|
|
193
|
+
expect(generateAgentName('/projects/AI DevKit', 123)).toBe('ai-devkit-123');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should convert uppercase to lowercase', () => {
|
|
197
|
+
expect(generateAgentName('/projects/MyProject', 456)).toBe('myproject-456');
|
|
190
198
|
});
|
|
191
199
|
});
|
|
@@ -45,12 +45,11 @@ export interface AgentInfo {
|
|
|
45
45
|
/** Session UUID */
|
|
46
46
|
sessionId: string;
|
|
47
47
|
|
|
48
|
-
/** Human-readable session name (e.g., "merry-wobbling-starlight"), may be undefined for new sessions */
|
|
49
|
-
slug?: string;
|
|
50
|
-
|
|
51
48
|
/** Timestamp of last activity */
|
|
52
49
|
lastActive: Date;
|
|
53
50
|
|
|
51
|
+
/** Path to the session JSONL file on disk */
|
|
52
|
+
sessionFilePath?: string;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
/**
|
|
@@ -73,9 +72,18 @@ export interface ProcessInfo {
|
|
|
73
72
|
startTime?: Date;
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
/**
|
|
76
|
+
* A single message in a conversation
|
|
77
|
+
*/
|
|
78
|
+
export interface ConversationMessage {
|
|
79
|
+
role: 'user' | 'assistant' | 'system';
|
|
80
|
+
content: string;
|
|
81
|
+
timestamp?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
/**
|
|
77
85
|
* Agent Adapter Interface
|
|
78
|
-
*
|
|
86
|
+
*
|
|
79
87
|
* Implementations must provide detection logic for a specific agent type.
|
|
80
88
|
*/
|
|
81
89
|
export interface AgentAdapter {
|
|
@@ -94,4 +102,12 @@ export interface AgentAdapter {
|
|
|
94
102
|
* @returns True if this adapter can handle the process
|
|
95
103
|
*/
|
|
96
104
|
canHandle(processInfo: ProcessInfo): boolean;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the full conversation from a session file
|
|
108
|
+
* @param sessionFilePath Path to the session JSONL file
|
|
109
|
+
* @param options.verbose Include tool call/result details
|
|
110
|
+
* @returns Array of conversation messages
|
|
111
|
+
*/
|
|
112
|
+
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[];
|
|
97
113
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
|
|
3
|
+
import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
|
|
4
4
|
import { AgentStatus } from './AgentAdapter';
|
|
5
5
|
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
6
6
|
import { batchGetSessionFileBirthtimes } from '../utils/session';
|
|
@@ -9,17 +9,22 @@ import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
|
9
9
|
/**
|
|
10
10
|
* Entry in session JSONL file
|
|
11
11
|
*/
|
|
12
|
+
interface ContentBlock {
|
|
13
|
+
type?: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
content?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
input?: Record<string, unknown>;
|
|
18
|
+
tool_use_id?: string;
|
|
19
|
+
is_error?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
interface SessionEntry {
|
|
13
23
|
type?: string;
|
|
14
24
|
timestamp?: string;
|
|
15
|
-
slug?: string;
|
|
16
25
|
cwd?: string;
|
|
17
26
|
message?: {
|
|
18
|
-
content?: string |
|
|
19
|
-
type?: string;
|
|
20
|
-
text?: string;
|
|
21
|
-
content?: string;
|
|
22
|
-
}>;
|
|
27
|
+
content?: string | ContentBlock[];
|
|
23
28
|
};
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -50,7 +55,6 @@ interface ClaudeSession {
|
|
|
50
55
|
sessionId: string;
|
|
51
56
|
projectPath: string;
|
|
52
57
|
lastCwd?: string;
|
|
53
|
-
slug?: string;
|
|
54
58
|
sessionStart: Date;
|
|
55
59
|
lastActive: Date;
|
|
56
60
|
lastEntryType?: string;
|
|
@@ -275,8 +279,8 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
275
279
|
pid: processInfo.pid,
|
|
276
280
|
projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
|
|
277
281
|
sessionId: sessionFile.sessionId,
|
|
278
|
-
slug: session.slug,
|
|
279
282
|
lastActive: session.lastActive,
|
|
283
|
+
sessionFilePath: sessionFile.filePath,
|
|
280
284
|
};
|
|
281
285
|
}
|
|
282
286
|
|
|
@@ -333,7 +337,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
// Parse all lines for session state (file already in memory)
|
|
336
|
-
let slug: string | undefined;
|
|
337
340
|
let lastEntryType: string | undefined;
|
|
338
341
|
let lastActive: Date | undefined;
|
|
339
342
|
let lastCwd: string | undefined;
|
|
@@ -351,10 +354,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
351
354
|
}
|
|
352
355
|
}
|
|
353
356
|
|
|
354
|
-
if (entry.slug && !slug) {
|
|
355
|
-
slug = entry.slug;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
357
|
if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
|
|
359
358
|
lastCwd = entry.cwd;
|
|
360
359
|
}
|
|
@@ -392,7 +391,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
392
391
|
sessionId,
|
|
393
392
|
projectPath: projectPath || lastCwd || '',
|
|
394
393
|
lastCwd,
|
|
395
|
-
slug,
|
|
396
394
|
sessionStart: sessionStart || lastActive || new Date(),
|
|
397
395
|
lastActive: lastActive || new Date(),
|
|
398
396
|
lastEntryType,
|
|
@@ -517,4 +515,102 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
517
515
|
return type === 'last-prompt' || type === 'file-history-snapshot';
|
|
518
516
|
}
|
|
519
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Read the full conversation from a Claude Code session JSONL file.
|
|
520
|
+
*
|
|
521
|
+
* Default mode returns only text content from user/assistant/system messages.
|
|
522
|
+
* Verbose mode also includes tool_use and tool_result blocks.
|
|
523
|
+
*/
|
|
524
|
+
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
525
|
+
const verbose = options?.verbose ?? false;
|
|
526
|
+
|
|
527
|
+
let content: string;
|
|
528
|
+
try {
|
|
529
|
+
content = fs.readFileSync(sessionFilePath, 'utf-8');
|
|
530
|
+
} catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const lines = content.trim().split('\n');
|
|
535
|
+
const messages: ConversationMessage[] = [];
|
|
536
|
+
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
let entry: SessionEntry;
|
|
539
|
+
try {
|
|
540
|
+
entry = JSON.parse(line);
|
|
541
|
+
} catch {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const entryType = entry.type;
|
|
546
|
+
if (!entryType || this.isMetadataEntryType(entryType)) continue;
|
|
547
|
+
if (entryType === 'progress' || entryType === 'thinking') continue;
|
|
548
|
+
|
|
549
|
+
let role: ConversationMessage['role'];
|
|
550
|
+
if (entryType === 'user') {
|
|
551
|
+
role = 'user';
|
|
552
|
+
} else if (entryType === 'assistant') {
|
|
553
|
+
role = 'assistant';
|
|
554
|
+
} else if (entryType === 'system') {
|
|
555
|
+
role = 'system';
|
|
556
|
+
} else {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const text = this.extractConversationContent(entry.message?.content, role, verbose);
|
|
561
|
+
if (!text) continue;
|
|
562
|
+
|
|
563
|
+
messages.push({
|
|
564
|
+
role,
|
|
565
|
+
content: text,
|
|
566
|
+
timestamp: entry.timestamp,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return messages;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Extract displayable content from a message content field.
|
|
575
|
+
*/
|
|
576
|
+
private extractConversationContent(
|
|
577
|
+
content: string | ContentBlock[] | undefined,
|
|
578
|
+
role: ConversationMessage['role'],
|
|
579
|
+
verbose: boolean,
|
|
580
|
+
): string | undefined {
|
|
581
|
+
if (!content) return undefined;
|
|
582
|
+
|
|
583
|
+
if (typeof content === 'string') {
|
|
584
|
+
const trimmed = content.trim();
|
|
585
|
+
if (role === 'user' && this.isNoiseMessage(trimmed)) return undefined;
|
|
586
|
+
return trimmed || undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (!Array.isArray(content)) return undefined;
|
|
590
|
+
|
|
591
|
+
const parts: string[] = [];
|
|
592
|
+
|
|
593
|
+
for (const block of content) {
|
|
594
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
595
|
+
if (role === 'user' && this.isNoiseMessage(block.text.trim())) continue;
|
|
596
|
+
parts.push(block.text.trim());
|
|
597
|
+
} else if (block.type === 'tool_use' && verbose) {
|
|
598
|
+
const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
|
|
599
|
+
parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
|
|
600
|
+
} else if (block.type === 'tool_result' && verbose) {
|
|
601
|
+
const truncated = this.truncateToolResult(block.content || '');
|
|
602
|
+
const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]';
|
|
603
|
+
parts.push(`${prefix} ${truncated}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private truncateToolResult(content: string, maxLength = 200): string {
|
|
611
|
+
const firstLine = content.split('\n')[0] || '';
|
|
612
|
+
if (firstLine.length <= maxLength) return firstLine;
|
|
613
|
+
return firstLine.slice(0, maxLength - 3) + '...';
|
|
614
|
+
}
|
|
615
|
+
|
|
520
616
|
}
|