@doingdev/opencode-claude-manager-plugin 0.1.35 → 0.1.43
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/claude/claude-agent-sdk-adapter.js +1 -0
- package/dist/manager/git-operations.d.ts +10 -1
- package/dist/manager/git-operations.js +18 -3
- package/dist/manager/persistent-manager.d.ts +19 -3
- package/dist/manager/persistent-manager.js +21 -9
- package/dist/manager/session-controller.d.ts +8 -5
- package/dist/manager/session-controller.js +25 -20
- package/dist/metadata/claude-metadata.service.d.ts +12 -0
- package/dist/metadata/claude-metadata.service.js +38 -0
- package/dist/metadata/repo-claude-config-reader.d.ts +7 -0
- package/dist/metadata/repo-claude-config-reader.js +154 -0
- package/dist/plugin/agent-hierarchy.d.ts +9 -9
- package/dist/plugin/agent-hierarchy.js +25 -25
- package/dist/plugin/claude-manager.plugin.js +83 -46
- package/dist/plugin/orchestrator.plugin.d.ts +2 -0
- package/dist/plugin/orchestrator.plugin.js +116 -0
- package/dist/plugin/service-factory.js +3 -8
- package/dist/prompts/registry.js +100 -103
- package/dist/providers/claude-code-wrapper.d.ts +13 -0
- package/dist/providers/claude-code-wrapper.js +13 -0
- package/dist/safety/bash-safety.d.ts +21 -0
- package/dist/safety/bash-safety.js +62 -0
- package/dist/src/claude/claude-agent-sdk-adapter.d.ts +27 -0
- package/dist/src/claude/claude-agent-sdk-adapter.js +517 -0
- package/dist/src/claude/claude-session.service.d.ts +10 -0
- package/dist/src/claude/claude-session.service.js +18 -0
- package/dist/src/claude/session-live-tailer.d.ts +51 -0
- package/dist/src/claude/session-live-tailer.js +269 -0
- package/dist/src/claude/tool-approval-manager.d.ts +27 -0
- package/dist/src/claude/tool-approval-manager.js +232 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +4 -0
- package/dist/src/manager/context-tracker.d.ts +33 -0
- package/dist/src/manager/context-tracker.js +106 -0
- package/dist/src/manager/git-operations.d.ts +12 -0
- package/dist/src/manager/git-operations.js +76 -0
- package/dist/src/manager/persistent-manager.d.ts +77 -0
- package/dist/src/manager/persistent-manager.js +170 -0
- package/dist/src/manager/session-controller.d.ts +44 -0
- package/dist/src/manager/session-controller.js +147 -0
- package/dist/src/plugin/agent-hierarchy.d.ts +60 -0
- package/dist/src/plugin/agent-hierarchy.js +157 -0
- package/dist/src/plugin/claude-manager.plugin.d.ts +2 -0
- package/dist/src/plugin/claude-manager.plugin.js +563 -0
- package/dist/src/plugin/service-factory.d.ts +12 -0
- package/dist/src/plugin/service-factory.js +38 -0
- package/dist/src/prompts/registry.d.ts +11 -0
- package/dist/src/prompts/registry.js +260 -0
- package/dist/src/state/file-run-state-store.d.ts +14 -0
- package/dist/src/state/file-run-state-store.js +85 -0
- package/dist/src/state/transcript-store.d.ts +15 -0
- package/dist/src/state/transcript-store.js +44 -0
- package/dist/src/types/contracts.d.ts +200 -0
- package/dist/src/types/contracts.js +1 -0
- package/dist/src/util/fs-helpers.d.ts +2 -0
- package/dist/src/util/fs-helpers.js +10 -0
- package/dist/src/util/project-context.d.ts +10 -0
- package/dist/src/util/project-context.js +105 -0
- package/dist/src/util/transcript-append.d.ts +7 -0
- package/dist/src/util/transcript-append.js +29 -0
- package/dist/test/claude-agent-sdk-adapter.test.d.ts +1 -0
- package/dist/test/claude-agent-sdk-adapter.test.js +459 -0
- package/dist/test/claude-manager.plugin.test.d.ts +1 -0
- package/dist/test/claude-manager.plugin.test.js +331 -0
- package/dist/test/context-tracker.test.d.ts +1 -0
- package/dist/test/context-tracker.test.js +138 -0
- package/dist/test/file-run-state-store.test.d.ts +1 -0
- package/dist/test/file-run-state-store.test.js +82 -0
- package/dist/test/git-operations.test.d.ts +1 -0
- package/dist/test/git-operations.test.js +90 -0
- package/dist/test/persistent-manager.test.d.ts +1 -0
- package/dist/test/persistent-manager.test.js +208 -0
- package/dist/test/project-context.test.d.ts +1 -0
- package/dist/test/project-context.test.js +92 -0
- package/dist/test/prompt-registry.test.d.ts +1 -0
- package/dist/test/prompt-registry.test.js +256 -0
- package/dist/test/session-controller.test.d.ts +1 -0
- package/dist/test/session-controller.test.js +149 -0
- package/dist/test/session-live-tailer.test.d.ts +1 -0
- package/dist/test/session-live-tailer.test.js +313 -0
- package/dist/test/tool-approval-manager.test.d.ts +1 -0
- package/dist/test/tool-approval-manager.test.js +264 -0
- package/dist/test/transcript-append.test.d.ts +1 -0
- package/dist/test/transcript-append.test.js +37 -0
- package/dist/test/transcript-store.test.d.ts +1 -0
- package/dist/test/transcript-store.test.js +50 -0
- package/dist/types/contracts.d.ts +3 -4
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +11 -0
- package/package.json +2 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { SessionController } from '../src/manager/session-controller.js';
|
|
3
|
+
import { ContextTracker } from '../src/manager/context-tracker.js';
|
|
4
|
+
function createMockAdapter(results = [{}]) {
|
|
5
|
+
let callIndex = 0;
|
|
6
|
+
return {
|
|
7
|
+
runSession: vi.fn(async () => {
|
|
8
|
+
const base = {
|
|
9
|
+
sessionId: 'ses_test',
|
|
10
|
+
events: [],
|
|
11
|
+
finalText: 'Done.',
|
|
12
|
+
turns: 1,
|
|
13
|
+
totalCostUsd: 0.01,
|
|
14
|
+
inputTokens: 10_000,
|
|
15
|
+
outputTokens: 500,
|
|
16
|
+
contextWindowSize: 200_000,
|
|
17
|
+
};
|
|
18
|
+
const overrides = results[callIndex] ?? {};
|
|
19
|
+
callIndex++;
|
|
20
|
+
return { ...base, ...overrides };
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
describe('SessionController', () => {
|
|
25
|
+
it('creates a new session on first sendMessage', async () => {
|
|
26
|
+
const adapter = createMockAdapter();
|
|
27
|
+
const tracker = new ContextTracker();
|
|
28
|
+
const controller = new SessionController(adapter, tracker, 'test prompt');
|
|
29
|
+
expect(controller.isActive).toBe(false);
|
|
30
|
+
const result = await controller.sendMessage('/tmp', 'hello');
|
|
31
|
+
expect(controller.isActive).toBe(true);
|
|
32
|
+
expect(controller.sessionId).toBe('ses_test');
|
|
33
|
+
expect(result.finalText).toBe('Done.');
|
|
34
|
+
// Verify system prompt was used (no resume)
|
|
35
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
36
|
+
expect(call.systemPrompt).toBe('test prompt');
|
|
37
|
+
expect(call.resumeSessionId).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
it('sends settingSources as user-only', async () => {
|
|
40
|
+
const adapter = createMockAdapter();
|
|
41
|
+
const tracker = new ContextTracker();
|
|
42
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
43
|
+
await controller.sendMessage('/tmp', 'hello');
|
|
44
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
45
|
+
expect(call.settingSources).toEqual(['user']);
|
|
46
|
+
});
|
|
47
|
+
it('resumes session on subsequent sends', async () => {
|
|
48
|
+
const adapter = createMockAdapter([{}, {}]);
|
|
49
|
+
const tracker = new ContextTracker();
|
|
50
|
+
const controller = new SessionController(adapter, tracker, 'test prompt');
|
|
51
|
+
await controller.sendMessage('/tmp', 'first');
|
|
52
|
+
await controller.sendMessage('/tmp', 'second');
|
|
53
|
+
const secondCall = adapter.runSession.mock.calls[1][0];
|
|
54
|
+
expect(secondCall.resumeSessionId).toBe('ses_test');
|
|
55
|
+
expect(secondCall.systemPrompt).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
it('updates context tracker on each message', async () => {
|
|
58
|
+
const adapter = createMockAdapter([{ turns: 3, totalCostUsd: 0.05, inputTokens: 50_000 }]);
|
|
59
|
+
const tracker = new ContextTracker();
|
|
60
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
61
|
+
await controller.sendMessage('/tmp', 'task');
|
|
62
|
+
const snap = controller.getContextSnapshot();
|
|
63
|
+
expect(snap.totalTurns).toBe(3);
|
|
64
|
+
expect(snap.totalCostUsd).toBe(0.05);
|
|
65
|
+
expect(snap.latestInputTokens).toBe(50_000);
|
|
66
|
+
expect(snap.estimatedContextPercent).toBe(25);
|
|
67
|
+
});
|
|
68
|
+
it('clears session and resets context', async () => {
|
|
69
|
+
const adapter = createMockAdapter();
|
|
70
|
+
const tracker = new ContextTracker();
|
|
71
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
72
|
+
await controller.sendMessage('/tmp', 'task');
|
|
73
|
+
expect(controller.isActive).toBe(true);
|
|
74
|
+
const clearedId = await controller.clearSession('/tmp');
|
|
75
|
+
expect(clearedId).toBe('ses_test');
|
|
76
|
+
expect(controller.isActive).toBe(false);
|
|
77
|
+
expect(controller.sessionId).toBeNull();
|
|
78
|
+
const snap = controller.getContextSnapshot();
|
|
79
|
+
expect(snap.totalTurns).toBe(0);
|
|
80
|
+
expect(snap.sessionId).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
it('sends /compact to current session', async () => {
|
|
83
|
+
const adapter = createMockAdapter([{}, {}]);
|
|
84
|
+
const tracker = new ContextTracker();
|
|
85
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
86
|
+
await controller.sendMessage('/tmp', 'task');
|
|
87
|
+
await controller.compactSession('/tmp');
|
|
88
|
+
const compactCall = adapter.runSession.mock.calls[1][0];
|
|
89
|
+
expect(compactCall.prompt).toBe('/compact');
|
|
90
|
+
expect(compactCall.resumeSessionId).toBe('ses_test');
|
|
91
|
+
});
|
|
92
|
+
it('throws when compacting without active session', async () => {
|
|
93
|
+
const adapter = createMockAdapter();
|
|
94
|
+
const tracker = new ContextTracker();
|
|
95
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
96
|
+
await expect(controller.compactSession('/tmp')).rejects.toThrow('No active session to compact');
|
|
97
|
+
});
|
|
98
|
+
it('threads effort through to the SDK input', async () => {
|
|
99
|
+
const adapter = createMockAdapter();
|
|
100
|
+
const tracker = new ContextTracker();
|
|
101
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
102
|
+
await controller.sendMessage('/tmp', 'hard task', { effort: 'max' });
|
|
103
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
104
|
+
expect(call.effort).toBe('max');
|
|
105
|
+
});
|
|
106
|
+
describe('plan/free mode', () => {
|
|
107
|
+
const modePrefixes = {
|
|
108
|
+
plan: '[PLAN MODE] Read-only planning.',
|
|
109
|
+
free: '',
|
|
110
|
+
};
|
|
111
|
+
it('defaults to free mode with acceptEdits permissionMode', async () => {
|
|
112
|
+
const adapter = createMockAdapter();
|
|
113
|
+
const tracker = new ContextTracker();
|
|
114
|
+
const controller = new SessionController(adapter, tracker, 'test', modePrefixes);
|
|
115
|
+
await controller.sendMessage('/tmp', 'do something');
|
|
116
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
117
|
+
expect(call.permissionMode).toBe('acceptEdits');
|
|
118
|
+
expect(call.prompt).toBe('do something');
|
|
119
|
+
});
|
|
120
|
+
it('sets permissionMode to plan and prepends prefix in plan mode', async () => {
|
|
121
|
+
const adapter = createMockAdapter();
|
|
122
|
+
const tracker = new ContextTracker();
|
|
123
|
+
const controller = new SessionController(adapter, tracker, 'test', modePrefixes);
|
|
124
|
+
await controller.sendMessage('/tmp', 'analyze this', { mode: 'plan' });
|
|
125
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
126
|
+
expect(call.permissionMode).toBe('plan');
|
|
127
|
+
expect(call.prompt).toBe('[PLAN MODE] Read-only planning.\n\nanalyze this');
|
|
128
|
+
});
|
|
129
|
+
it('explicit free mode uses acceptEdits and no prefix', async () => {
|
|
130
|
+
const adapter = createMockAdapter();
|
|
131
|
+
const tracker = new ContextTracker();
|
|
132
|
+
const controller = new SessionController(adapter, tracker, 'test', modePrefixes);
|
|
133
|
+
await controller.sendMessage('/tmp', 'build it', { mode: 'free' });
|
|
134
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
135
|
+
expect(call.permissionMode).toBe('acceptEdits');
|
|
136
|
+
expect(call.prompt).toBe('build it');
|
|
137
|
+
});
|
|
138
|
+
it('works without modePrefixes constructor arg (backward compat)', async () => {
|
|
139
|
+
const adapter = createMockAdapter();
|
|
140
|
+
const tracker = new ContextTracker();
|
|
141
|
+
const controller = new SessionController(adapter, tracker, 'test');
|
|
142
|
+
await controller.sendMessage('/tmp', 'hello', { mode: 'plan' });
|
|
143
|
+
const call = adapter.runSession.mock.calls[0][0];
|
|
144
|
+
expect(call.permissionMode).toBe('plan');
|
|
145
|
+
// Empty prefix defaults — prompt should be unchanged
|
|
146
|
+
expect(call.prompt).toBe('hello');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { SessionLiveTailer } from '../src/claude/session-live-tailer.js';
|
|
7
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
8
|
+
let tmpDir;
|
|
9
|
+
let projectsDir;
|
|
10
|
+
function sessionDir(projectName) {
|
|
11
|
+
const dir = path.join(projectsDir, projectName, 'sessions');
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
function writeSession(projectName, sessionId, lines) {
|
|
16
|
+
const dir = sessionDir(projectName);
|
|
17
|
+
const filePath = path.join(dir, `${sessionId}.jsonl`);
|
|
18
|
+
writeFileSync(filePath, lines.join('\n') + '\n', 'utf8');
|
|
19
|
+
return filePath;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build a tailer that looks in our temp dir instead of ~/.claude/projects.
|
|
23
|
+
* We override `findSessionFile` to search the temp projects dir.
|
|
24
|
+
*/
|
|
25
|
+
function createTailer() {
|
|
26
|
+
const tailer = new SessionLiveTailer();
|
|
27
|
+
// Patch the internal discovery to use our temp dir.
|
|
28
|
+
tailer.findSessionFile = (sessionId, cwd) => {
|
|
29
|
+
if (!existsSync(projectsDir))
|
|
30
|
+
return null;
|
|
31
|
+
// Fast-path: sanitised cwd
|
|
32
|
+
if (cwd) {
|
|
33
|
+
const sanitised = cwd.replace(/\//g, '-');
|
|
34
|
+
const candidate = path.join(projectsDir, sanitised, 'sessions', `${sessionId}.jsonl`);
|
|
35
|
+
if (existsSync(candidate))
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
// Scan all project directories
|
|
39
|
+
try {
|
|
40
|
+
for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
|
|
41
|
+
if (!entry.isDirectory())
|
|
42
|
+
continue;
|
|
43
|
+
const candidate = path.join(projectsDir, entry.name, 'sessions', `${sessionId}.jsonl`);
|
|
44
|
+
if (existsSync(candidate))
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
return tailer;
|
|
54
|
+
}
|
|
55
|
+
// ── Setup / teardown ────────────────────────────────────────────────────
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'live-tailer-test-'));
|
|
58
|
+
projectsDir = path.join(tmpDir, '.claude', 'projects');
|
|
59
|
+
mkdirSync(projectsDir, { recursive: true });
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
// ── Tests ───────────────────────────────────────────────────────────────
|
|
65
|
+
describe('SessionLiveTailer', () => {
|
|
66
|
+
describe('findSessionFile', () => {
|
|
67
|
+
it('finds a session file by scanning project directories', () => {
|
|
68
|
+
writeSession('my-project', 'ses_abc', [JSON.stringify({ type: 'system', subtype: 'init' })]);
|
|
69
|
+
const tailer = createTailer();
|
|
70
|
+
const found = tailer.findSessionFile('ses_abc');
|
|
71
|
+
expect(found).not.toBeNull();
|
|
72
|
+
expect(found).toContain('ses_abc.jsonl');
|
|
73
|
+
});
|
|
74
|
+
it('returns null when session does not exist', () => {
|
|
75
|
+
const tailer = createTailer();
|
|
76
|
+
expect(tailer.findSessionFile('ses_nonexistent')).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
it('prefers sanitised cwd fast-path when cwd is provided', () => {
|
|
79
|
+
// Create two projects with the same session ID
|
|
80
|
+
writeSession('-home-user-proj', 'ses_dup', [
|
|
81
|
+
JSON.stringify({ type: 'system', data: 'fast-path' }),
|
|
82
|
+
]);
|
|
83
|
+
writeSession('other-project', 'ses_dup', [
|
|
84
|
+
JSON.stringify({ type: 'system', data: 'scan-path' }),
|
|
85
|
+
]);
|
|
86
|
+
const tailer = createTailer();
|
|
87
|
+
const found = tailer.findSessionFile('ses_dup', '/home/user/proj');
|
|
88
|
+
expect(found).not.toBeNull();
|
|
89
|
+
expect(found).toContain('-home-user-proj');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('sessionFileExists', () => {
|
|
93
|
+
it('returns true when the file exists', () => {
|
|
94
|
+
writeSession('proj', 'ses_exists', [JSON.stringify({ type: 'system' })]);
|
|
95
|
+
const tailer = createTailer();
|
|
96
|
+
expect(tailer.sessionFileExists('ses_exists')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it('returns false when the file does not exist', () => {
|
|
99
|
+
const tailer = createTailer();
|
|
100
|
+
expect(tailer.sessionFileExists('ses_missing')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('getLastLines', () => {
|
|
104
|
+
it('returns the last N lines from a JSONL file', async () => {
|
|
105
|
+
const lines = Array.from({ length: 20 }, (_, i) => JSON.stringify({ type: 'line', index: i }));
|
|
106
|
+
writeSession('proj', 'ses_lines', lines);
|
|
107
|
+
const tailer = createTailer();
|
|
108
|
+
const result = await tailer.getLastLines('ses_lines', undefined, 5);
|
|
109
|
+
expect(result).toHaveLength(5);
|
|
110
|
+
expect(JSON.parse(result[0])).toMatchObject({ index: 15 });
|
|
111
|
+
expect(JSON.parse(result[4])).toMatchObject({ index: 19 });
|
|
112
|
+
});
|
|
113
|
+
it('returns all lines when file has fewer than N lines', async () => {
|
|
114
|
+
writeSession('proj', 'ses_short', [
|
|
115
|
+
JSON.stringify({ type: 'a' }),
|
|
116
|
+
JSON.stringify({ type: 'b' }),
|
|
117
|
+
]);
|
|
118
|
+
const tailer = createTailer();
|
|
119
|
+
const result = await tailer.getLastLines('ses_short', undefined, 10);
|
|
120
|
+
expect(result).toHaveLength(2);
|
|
121
|
+
});
|
|
122
|
+
it('returns empty array when session file not found', async () => {
|
|
123
|
+
const tailer = createTailer();
|
|
124
|
+
const result = await tailer.getLastLines('ses_ghost', undefined, 5);
|
|
125
|
+
expect(result).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('getToolOutputPreview', () => {
|
|
129
|
+
it('extracts tool_result from user messages (pattern 1)', async () => {
|
|
130
|
+
const lines = [
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
type: 'assistant',
|
|
133
|
+
message: { content: [{ type: 'text', text: 'Searching...' }] },
|
|
134
|
+
}),
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
type: 'user',
|
|
137
|
+
message: {
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: 'tool_result',
|
|
141
|
+
tool_use_id: 'toolu_grep_1',
|
|
142
|
+
content: 'src/main.ts:42: const result = await fetch(url);',
|
|
143
|
+
is_error: false,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
JSON.stringify({
|
|
149
|
+
type: 'assistant',
|
|
150
|
+
message: { content: [{ type: 'text', text: 'Found the match.' }] },
|
|
151
|
+
}),
|
|
152
|
+
];
|
|
153
|
+
writeSession('proj', 'ses_tool', lines);
|
|
154
|
+
const tailer = createTailer();
|
|
155
|
+
const previews = await tailer.getToolOutputPreview('ses_tool', undefined, 5);
|
|
156
|
+
expect(previews).toHaveLength(1);
|
|
157
|
+
expect(previews[0]).toMatchObject({
|
|
158
|
+
toolUseId: 'toolu_grep_1',
|
|
159
|
+
content: 'src/main.ts:42: const result = await fetch(url);',
|
|
160
|
+
isError: false,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
it('extracts direct tool_result records (pattern 2)', async () => {
|
|
164
|
+
const lines = [
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
type: 'tool_result',
|
|
167
|
+
tool_use_id: 'toolu_bash_1',
|
|
168
|
+
content: 'npm test: 42 passed, 0 failed',
|
|
169
|
+
is_error: false,
|
|
170
|
+
}),
|
|
171
|
+
];
|
|
172
|
+
writeSession('proj', 'ses_direct', lines);
|
|
173
|
+
const tailer = createTailer();
|
|
174
|
+
const previews = await tailer.getToolOutputPreview('ses_direct', undefined, 5);
|
|
175
|
+
expect(previews).toHaveLength(1);
|
|
176
|
+
expect(previews[0]).toMatchObject({
|
|
177
|
+
toolUseId: 'toolu_bash_1',
|
|
178
|
+
content: 'npm test: 42 passed, 0 failed',
|
|
179
|
+
isError: false,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it('handles error tool results', async () => {
|
|
183
|
+
const lines = [
|
|
184
|
+
JSON.stringify({
|
|
185
|
+
type: 'tool_result',
|
|
186
|
+
tool_use_id: 'toolu_err',
|
|
187
|
+
content: 'ENOENT: no such file',
|
|
188
|
+
is_error: true,
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
writeSession('proj', 'ses_err', lines);
|
|
192
|
+
const tailer = createTailer();
|
|
193
|
+
const previews = await tailer.getToolOutputPreview('ses_err', undefined, 5);
|
|
194
|
+
expect(previews).toHaveLength(1);
|
|
195
|
+
expect(previews[0].isError).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
it('limits results to maxEntries', async () => {
|
|
198
|
+
const lines = Array.from({ length: 10 }, (_, i) => JSON.stringify({
|
|
199
|
+
type: 'tool_result',
|
|
200
|
+
tool_use_id: `toolu_${i}`,
|
|
201
|
+
content: `result ${i}`,
|
|
202
|
+
is_error: false,
|
|
203
|
+
}));
|
|
204
|
+
writeSession('proj', 'ses_limit', lines);
|
|
205
|
+
const tailer = createTailer();
|
|
206
|
+
const previews = await tailer.getToolOutputPreview('ses_limit', undefined, 3);
|
|
207
|
+
expect(previews).toHaveLength(3);
|
|
208
|
+
// Should return the last 3
|
|
209
|
+
expect(previews[0].toolUseId).toBe('toolu_7');
|
|
210
|
+
expect(previews[2].toolUseId).toBe('toolu_9');
|
|
211
|
+
});
|
|
212
|
+
it('skips non-tool-result lines', async () => {
|
|
213
|
+
const lines = [
|
|
214
|
+
JSON.stringify({ type: 'system', subtype: 'init' }),
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
type: 'assistant',
|
|
217
|
+
message: { content: [{ type: 'text', text: 'hi' }] },
|
|
218
|
+
}),
|
|
219
|
+
JSON.stringify({
|
|
220
|
+
type: 'tool_result',
|
|
221
|
+
tool_use_id: 'toolu_1',
|
|
222
|
+
content: 'output',
|
|
223
|
+
is_error: false,
|
|
224
|
+
}),
|
|
225
|
+
JSON.stringify({ type: 'result', subtype: 'success', result: 'Done' }),
|
|
226
|
+
];
|
|
227
|
+
writeSession('proj', 'ses_mixed', lines);
|
|
228
|
+
const tailer = createTailer();
|
|
229
|
+
const previews = await tailer.getToolOutputPreview('ses_mixed', undefined, 10);
|
|
230
|
+
expect(previews).toHaveLength(1);
|
|
231
|
+
expect(previews[0].toolUseId).toBe('toolu_1');
|
|
232
|
+
});
|
|
233
|
+
it('returns empty array when session not found', async () => {
|
|
234
|
+
const tailer = createTailer();
|
|
235
|
+
const previews = await tailer.getToolOutputPreview('ses_nope', undefined);
|
|
236
|
+
expect(previews).toEqual([]);
|
|
237
|
+
});
|
|
238
|
+
it('handles non-string content by JSON-stringifying it', async () => {
|
|
239
|
+
const lines = [
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
type: 'tool_result',
|
|
242
|
+
tool_use_id: 'toolu_obj',
|
|
243
|
+
content: [{ type: 'text', text: 'structured output' }],
|
|
244
|
+
is_error: false,
|
|
245
|
+
}),
|
|
246
|
+
];
|
|
247
|
+
writeSession('proj', 'ses_obj', lines);
|
|
248
|
+
const tailer = createTailer();
|
|
249
|
+
const previews = await tailer.getToolOutputPreview('ses_obj', undefined, 5);
|
|
250
|
+
expect(previews).toHaveLength(1);
|
|
251
|
+
expect(previews[0].content).toBe(JSON.stringify([{ type: 'text', text: 'structured output' }]));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe('startTailing', () => {
|
|
255
|
+
it('emits error event when session file not found', () => {
|
|
256
|
+
const tailer = createTailer();
|
|
257
|
+
const events = [];
|
|
258
|
+
const stop = tailer.startTailing('ses_missing', undefined, (e) => events.push(e));
|
|
259
|
+
expect(events).toHaveLength(1);
|
|
260
|
+
expect(events[0].type).toBe('error');
|
|
261
|
+
expect(events[0].error).toContain('ses_missing');
|
|
262
|
+
stop();
|
|
263
|
+
});
|
|
264
|
+
it('picks up new lines appended after tailing starts', async () => {
|
|
265
|
+
// Write initial content.
|
|
266
|
+
const filePath = writeSession('proj', 'ses_tail', [
|
|
267
|
+
JSON.stringify({ type: 'system', subtype: 'init' }),
|
|
268
|
+
]);
|
|
269
|
+
const tailer = createTailer();
|
|
270
|
+
const events = [];
|
|
271
|
+
// Start tailing (offset begins at end of existing content).
|
|
272
|
+
const stop = tailer.startTailing('ses_tail', undefined, (e) => events.push(e), 50);
|
|
273
|
+
// Append a new line after a short delay.
|
|
274
|
+
await sleep(80);
|
|
275
|
+
appendFileSync(filePath, JSON.stringify({
|
|
276
|
+
type: 'tool_result',
|
|
277
|
+
tool_use_id: 'toolu_new',
|
|
278
|
+
content: 'hello',
|
|
279
|
+
}) + '\n');
|
|
280
|
+
// Wait for the poll to pick it up.
|
|
281
|
+
await sleep(200);
|
|
282
|
+
stop();
|
|
283
|
+
const lineEvents = events.filter((e) => e.type === 'line');
|
|
284
|
+
expect(lineEvents.length).toBeGreaterThanOrEqual(1);
|
|
285
|
+
expect(lineEvents[0].data).toMatchObject({
|
|
286
|
+
type: 'tool_result',
|
|
287
|
+
tool_use_id: 'toolu_new',
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
it('stopTailing clears the interval', () => {
|
|
291
|
+
writeSession('proj', 'ses_stop', [JSON.stringify({ type: 'system' })]);
|
|
292
|
+
const tailer = createTailer();
|
|
293
|
+
const stop = tailer.startTailing('ses_stop', undefined, () => { }, 50);
|
|
294
|
+
// Stop should not throw and should be idempotent.
|
|
295
|
+
stop();
|
|
296
|
+
stop();
|
|
297
|
+
tailer.stopTailing('ses_stop');
|
|
298
|
+
});
|
|
299
|
+
it('stopAll clears all active tails', () => {
|
|
300
|
+
writeSession('proj', 'ses_a', [JSON.stringify({ type: 'system' })]);
|
|
301
|
+
writeSession('proj', 'ses_b', [JSON.stringify({ type: 'system' })]);
|
|
302
|
+
const tailer = createTailer();
|
|
303
|
+
tailer.startTailing('ses_a', undefined, () => { }, 50);
|
|
304
|
+
tailer.startTailing('ses_b', undefined, () => { }, 50);
|
|
305
|
+
tailer.stopAll();
|
|
306
|
+
// Verify idempotent — should not throw.
|
|
307
|
+
tailer.stopAll();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
function sleep(ms) {
|
|
312
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
313
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|