@fixy/core 0.0.0
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/__tests__/diff-parser.test.d.ts +2 -0
- package/dist/__tests__/diff-parser.test.d.ts.map +1 -0
- package/dist/__tests__/diff-parser.test.js +89 -0
- package/dist/__tests__/diff-parser.test.js.map +1 -0
- package/dist/__tests__/fixy-commands.test.d.ts +2 -0
- package/dist/__tests__/fixy-commands.test.d.ts.map +1 -0
- package/dist/__tests__/fixy-commands.test.js +176 -0
- package/dist/__tests__/fixy-commands.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +66 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +77 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/smoke.test.d.ts +2 -0
- package/dist/__tests__/smoke.test.d.ts.map +1 -0
- package/dist/__tests__/smoke.test.js +7 -0
- package/dist/__tests__/smoke.test.js.map +1 -0
- package/dist/__tests__/store.test.d.ts +2 -0
- package/dist/__tests__/store.test.d.ts.map +1 -0
- package/dist/__tests__/store.test.js +121 -0
- package/dist/__tests__/store.test.js.map +1 -0
- package/dist/__tests__/turn.test.d.ts +2 -0
- package/dist/__tests__/turn.test.d.ts.map +1 -0
- package/dist/__tests__/turn.test.js +194 -0
- package/dist/__tests__/turn.test.js.map +1 -0
- package/dist/__tests__/worktree.test.d.ts +2 -0
- package/dist/__tests__/worktree.test.d.ts.map +1 -0
- package/dist/__tests__/worktree.test.js +119 -0
- package/dist/__tests__/worktree.test.js.map +1 -0
- package/dist/adapter.d.ts +75 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +3 -0
- package/dist/adapter.js.map +1 -0
- package/dist/diff-parser.d.ts +3 -0
- package/dist/diff-parser.d.ts.map +1 -0
- package/dist/diff-parser.js +38 -0
- package/dist/diff-parser.js.map +1 -0
- package/dist/fixy-commands.d.ts +25 -0
- package/dist/fixy-commands.d.ts.map +1 -0
- package/dist/fixy-commands.js +154 -0
- package/dist/fixy-commands.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +36 -0
- package/dist/paths.js.map +1 -0
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +32 -0
- package/dist/registry.js.map +1 -0
- package/dist/router.d.ts +21 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +36 -0
- package/dist/router.js.map +1 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +127 -0
- package/dist/store.js.map +1 -0
- package/dist/thread.d.ts +47 -0
- package/dist/thread.d.ts.map +1 -0
- package/dist/thread.js +3 -0
- package/dist/thread.js.map +1 -0
- package/dist/turn.d.ts +20 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +130 -0
- package/dist/turn.js.map +1 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +91 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/diff-parser.test.ts +99 -0
- package/src/__tests__/fixy-commands.test.ts +231 -0
- package/src/__tests__/registry.test.ts +79 -0
- package/src/__tests__/router.test.ts +91 -0
- package/src/__tests__/smoke.test.ts +7 -0
- package/src/__tests__/store.test.ts +151 -0
- package/src/__tests__/turn.test.ts +266 -0
- package/src/__tests__/worktree.test.ts +155 -0
- package/src/adapter.ts +84 -0
- package/src/diff-parser.ts +46 -0
- package/src/fixy-commands.ts +201 -0
- package/src/index.ts +40 -0
- package/src/paths.ts +43 -0
- package/src/registry.ts +40 -0
- package/src/router.ts +49 -0
- package/src/store.ts +164 -0
- package/src/thread.ts +50 -0
- package/src/turn.ts +165 -0
- package/src/worktree.ts +119 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { AdapterRegistry } from '../registry.js';
|
|
3
|
+
import type { FixyAdapter } from '../adapter.js';
|
|
4
|
+
|
|
5
|
+
function createStubAdapter(id: string, name: string): FixyAdapter {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
name,
|
|
9
|
+
probe: async () => ({
|
|
10
|
+
available: true,
|
|
11
|
+
version: '1.0.0',
|
|
12
|
+
authStatus: 'ok' as const,
|
|
13
|
+
detail: null,
|
|
14
|
+
}),
|
|
15
|
+
execute: async () => ({
|
|
16
|
+
exitCode: 0,
|
|
17
|
+
signal: null,
|
|
18
|
+
timedOut: false,
|
|
19
|
+
summary: '',
|
|
20
|
+
session: null,
|
|
21
|
+
patches: [],
|
|
22
|
+
warnings: [],
|
|
23
|
+
errorMessage: null,
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('AdapterRegistry', () => {
|
|
29
|
+
let registry: AdapterRegistry;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
registry = new AdapterRegistry();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('register() adds adapters and list() returns them', () => {
|
|
36
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
37
|
+
registry.register(createStubAdapter('codex', 'Codex'));
|
|
38
|
+
expect(registry.list()).toHaveLength(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('require() returns the correct adapter', () => {
|
|
42
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
43
|
+
expect(registry.require('claude').name).toBe('Claude');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('require() throws for unknown id', () => {
|
|
47
|
+
expect(() => registry.require('unknown')).toThrow('Unknown adapter: unknown');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('register() throws on duplicate id', () => {
|
|
51
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
52
|
+
expect(() => registry.register(createStubAdapter('claude', 'Claude 2'))).toThrow(
|
|
53
|
+
'Adapter already registered: claude',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('unregister() removes the adapter', () => {
|
|
58
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
59
|
+
registry.unregister('claude');
|
|
60
|
+
expect(registry.has('claude')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('unregister() does not throw for unknown id', () => {
|
|
64
|
+
expect(() => registry.unregister('nonexistent')).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('get() returns adapter or undefined', () => {
|
|
68
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
69
|
+
expect(registry.get('claude')?.id).toBe('claude');
|
|
70
|
+
expect(registry.get('unknown')).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('clear() removes all adapters', () => {
|
|
74
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
75
|
+
registry.register(createStubAdapter('codex', 'Codex'));
|
|
76
|
+
registry.clear();
|
|
77
|
+
expect(registry.list()).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// packages/core/src/__tests__/router.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
+
import { Router } from '../router.js';
|
|
5
|
+
import { AdapterRegistry } from '../registry.js';
|
|
6
|
+
import type { FixyAdapter } from '../adapter.js';
|
|
7
|
+
|
|
8
|
+
function createStubAdapter(id: string, name: string): FixyAdapter {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
name,
|
|
12
|
+
probe: async () => ({
|
|
13
|
+
available: true,
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
authStatus: 'ok' as const,
|
|
16
|
+
detail: null,
|
|
17
|
+
}),
|
|
18
|
+
execute: async () => ({
|
|
19
|
+
exitCode: 0,
|
|
20
|
+
signal: null,
|
|
21
|
+
timedOut: false,
|
|
22
|
+
summary: '',
|
|
23
|
+
session: null,
|
|
24
|
+
patches: [],
|
|
25
|
+
warnings: [],
|
|
26
|
+
errorMessage: null,
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('Router', () => {
|
|
32
|
+
let registry: AdapterRegistry;
|
|
33
|
+
let router: Router;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
registry = new AdapterRegistry();
|
|
37
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
38
|
+
registry.register(createStubAdapter('codex', 'Codex'));
|
|
39
|
+
router = new Router(registry);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rule 1: single mention dispatches to that adapter', () => {
|
|
43
|
+
const result = router.parse('@claude do something');
|
|
44
|
+
expect(result).toEqual({ kind: 'mention', agentIds: ['claude'], body: 'do something' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rule 2: multi mention dispatches to all in order', () => {
|
|
48
|
+
const result = router.parse('@claude @codex brainstorm');
|
|
49
|
+
expect(result).toEqual({ kind: 'mention', agentIds: ['claude', 'codex'], body: 'brainstorm' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rule 3: @fixy routes to fixy command handler', () => {
|
|
53
|
+
const result = router.parse('@fixy /status');
|
|
54
|
+
expect(result).toEqual({ kind: 'fixy', rest: '/status' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rule 3: @fixy /worker with args', () => {
|
|
58
|
+
const result = router.parse('@fixy /worker claude');
|
|
59
|
+
expect(result).toEqual({ kind: 'fixy', rest: '/worker claude' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rule 4: no mention falls to bare', () => {
|
|
63
|
+
const result = router.parse('just do it');
|
|
64
|
+
expect(result).toEqual({ kind: 'bare', body: 'just do it' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rule 5: unknown mention returns error', () => {
|
|
68
|
+
const result = router.parse('@unknown do something');
|
|
69
|
+
expect(result).toEqual({ kind: 'error', reason: 'unknown agent: @unknown' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('empty string returns bare', () => {
|
|
73
|
+
const result = router.parse('');
|
|
74
|
+
expect(result).toEqual({ kind: 'bare', body: '' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('@fixy alone with no rest', () => {
|
|
78
|
+
const result = router.parse('@fixy');
|
|
79
|
+
expect(result).toEqual({ kind: 'fixy', rest: '' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('mixed known and unknown mentions returns error for unknown', () => {
|
|
83
|
+
const result = router.parse('@claude @unknown brainstorm');
|
|
84
|
+
expect(result).toEqual({ kind: 'error', reason: 'unknown agent: @unknown' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('@fixy takes priority even with other mentions after', () => {
|
|
88
|
+
const result = router.parse('@fixy @claude do something');
|
|
89
|
+
expect(result).toEqual({ kind: 'fixy', rest: '@claude do something' });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// packages/core/src/__tests__/store.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { mkdtemp, rm, readdir } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
import { LocalThreadStore } from '../store.js';
|
|
10
|
+
import type { FixyMessage } from '../thread.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helper
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function makeMessage(content: string, role: 'user' | 'agent' | 'system' = 'user'): FixyMessage {
|
|
17
|
+
return {
|
|
18
|
+
id: randomUUID(),
|
|
19
|
+
createdAt: new Date().toISOString(),
|
|
20
|
+
role,
|
|
21
|
+
agentId: role === 'agent' ? 'claude' : null,
|
|
22
|
+
content,
|
|
23
|
+
runId: role === 'agent' ? randomUUID() : null,
|
|
24
|
+
dispatchedTo: role === 'user' ? ['claude'] : [],
|
|
25
|
+
patches: [],
|
|
26
|
+
warnings: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Setup / teardown
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
let tempDir: string;
|
|
35
|
+
let store: LocalThreadStore;
|
|
36
|
+
const PROJECT_ROOT = '/tmp/fake-project';
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
tempDir = await mkdtemp(join(tmpdir(), 'fixy-test-'));
|
|
40
|
+
process.env['FIXY_HOME'] = tempDir;
|
|
41
|
+
store = new LocalThreadStore();
|
|
42
|
+
await store.init();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
delete process.env['FIXY_HOME'];
|
|
47
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Tests
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe('LocalThreadStore', () => {
|
|
55
|
+
describe('createThread', () => {
|
|
56
|
+
it('creates a thread with correct projectRoot, status=active, and an id', async () => {
|
|
57
|
+
const thread = await store.createThread(PROJECT_ROOT);
|
|
58
|
+
|
|
59
|
+
expect(thread.id).toBeTruthy();
|
|
60
|
+
expect(typeof thread.id).toBe('string');
|
|
61
|
+
expect(thread.projectRoot).toBe(PROJECT_ROOT);
|
|
62
|
+
expect(thread.status).toBe('active');
|
|
63
|
+
|
|
64
|
+
// Verify the thread is also persisted on disk
|
|
65
|
+
const loaded = await store.getThread(thread.id, PROJECT_ROOT);
|
|
66
|
+
expect(loaded.id).toBe(thread.id);
|
|
67
|
+
expect(loaded.projectRoot).toBe(PROJECT_ROOT);
|
|
68
|
+
expect(loaded.status).toBe('active');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('appendMessage', () => {
|
|
73
|
+
it('appends multiple messages in order and persists them to disk', async () => {
|
|
74
|
+
const thread = await store.createThread(PROJECT_ROOT);
|
|
75
|
+
|
|
76
|
+
const msg1 = makeMessage('first message', 'user');
|
|
77
|
+
const msg2 = makeMessage('second message', 'agent');
|
|
78
|
+
const msg3 = makeMessage('third message', 'user');
|
|
79
|
+
|
|
80
|
+
await store.appendMessage(thread.id, PROJECT_ROOT, msg1);
|
|
81
|
+
await store.appendMessage(thread.id, PROJECT_ROOT, msg2);
|
|
82
|
+
await store.appendMessage(thread.id, PROJECT_ROOT, msg3);
|
|
83
|
+
|
|
84
|
+
// Reload from disk to confirm persistence
|
|
85
|
+
const loaded = await store.getThread(thread.id, PROJECT_ROOT);
|
|
86
|
+
expect(loaded.messages).toHaveLength(3);
|
|
87
|
+
expect(loaded.messages[0]).toHaveProperty('content', 'first message');
|
|
88
|
+
expect(loaded.messages[1]).toHaveProperty('content', 'second message');
|
|
89
|
+
expect(loaded.messages[2]).toHaveProperty('content', 'third message');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('getThread / listThreads', () => {
|
|
94
|
+
it('lists both threads and returns the correct one by id', async () => {
|
|
95
|
+
const threadA = await store.createThread(PROJECT_ROOT);
|
|
96
|
+
const threadB = await store.createThread(PROJECT_ROOT);
|
|
97
|
+
|
|
98
|
+
const threads = await store.listThreads(PROJECT_ROOT);
|
|
99
|
+
const ids = threads.map((t) => t.id);
|
|
100
|
+
expect(ids).toContain(threadA.id);
|
|
101
|
+
expect(ids).toContain(threadB.id);
|
|
102
|
+
expect(threads).toHaveLength(2);
|
|
103
|
+
|
|
104
|
+
const loadedA = await store.getThread(threadA.id, PROJECT_ROOT);
|
|
105
|
+
expect(loadedA.id).toBe(threadA.id);
|
|
106
|
+
|
|
107
|
+
const loadedB = await store.getThread(threadB.id, PROJECT_ROOT);
|
|
108
|
+
expect(loadedB.id).toBe(threadB.id);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('archiveThread', () => {
|
|
113
|
+
it('sets status to "archived" and persists the change', async () => {
|
|
114
|
+
const thread = await store.createThread(PROJECT_ROOT);
|
|
115
|
+
expect(thread.status).toBe('active');
|
|
116
|
+
|
|
117
|
+
const archived = await store.archiveThread(thread.id, PROJECT_ROOT);
|
|
118
|
+
expect(archived.status).toBe('archived');
|
|
119
|
+
|
|
120
|
+
// Reload from disk to confirm persistence
|
|
121
|
+
const loaded = await store.getThread(thread.id, PROJECT_ROOT);
|
|
122
|
+
expect(loaded.status).toBe('archived');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('atomic write safety', () => {
|
|
127
|
+
it('leaves no .tmp files in the threads directory after writes', async () => {
|
|
128
|
+
const thread = await store.createThread(PROJECT_ROOT);
|
|
129
|
+
await store.appendMessage(thread.id, PROJECT_ROOT, makeMessage('hello'));
|
|
130
|
+
|
|
131
|
+
// Derive the threads directory using the same path logic the store uses
|
|
132
|
+
const { getThreadsDir } = await import('../paths.js');
|
|
133
|
+
const threadsDir = getThreadsDir(PROJECT_ROOT);
|
|
134
|
+
|
|
135
|
+
const entries = await readdir(threadsDir);
|
|
136
|
+
const tmpFiles = entries.filter((name) => name.endsWith('.tmp'));
|
|
137
|
+
expect(tmpFiles).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('appendMessage on non-existent thread', () => {
|
|
142
|
+
it('throws an error containing "Thread not found"', async () => {
|
|
143
|
+
const fakeId = randomUUID();
|
|
144
|
+
const msg = makeMessage('should fail');
|
|
145
|
+
|
|
146
|
+
await expect(store.appendMessage(fakeId, PROJECT_ROOT, msg)).rejects.toThrow(
|
|
147
|
+
'Thread not found',
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// packages/core/src/__tests__/turn.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { TurnController } from '../turn.js';
|
|
8
|
+
import { AdapterRegistry } from '../registry.js';
|
|
9
|
+
import { LocalThreadStore } from '../store.js';
|
|
10
|
+
import type { FixyAdapter, FixyExecutionContext, FixyExecutionResult } from '../adapter.js';
|
|
11
|
+
import type { FixyThread } from '../thread.js';
|
|
12
|
+
import type { TurnParams } from '../turn.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function createStubAdapter(
|
|
19
|
+
id: string,
|
|
20
|
+
name: string,
|
|
21
|
+
executeFn?: (ctx: FixyExecutionContext) => Promise<FixyExecutionResult>,
|
|
22
|
+
): FixyAdapter {
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
name,
|
|
26
|
+
probe: async () => ({
|
|
27
|
+
available: true,
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
authStatus: 'ok' as const,
|
|
30
|
+
detail: null,
|
|
31
|
+
}),
|
|
32
|
+
execute:
|
|
33
|
+
executeFn ??
|
|
34
|
+
(async () => ({
|
|
35
|
+
exitCode: 0,
|
|
36
|
+
signal: null,
|
|
37
|
+
timedOut: false,
|
|
38
|
+
summary: `response from ${id}`,
|
|
39
|
+
session: null,
|
|
40
|
+
patches: [],
|
|
41
|
+
warnings: [],
|
|
42
|
+
errorMessage: null,
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Suite
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('TurnController', () => {
|
|
52
|
+
let tmpDir: string;
|
|
53
|
+
let store: LocalThreadStore;
|
|
54
|
+
let thread: FixyThread;
|
|
55
|
+
let registry: AdapterRegistry;
|
|
56
|
+
let controller: TurnController;
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'fixy-turn-test-'));
|
|
60
|
+
process.env['FIXY_HOME'] = tmpDir;
|
|
61
|
+
|
|
62
|
+
store = new LocalThreadStore();
|
|
63
|
+
await store.init();
|
|
64
|
+
|
|
65
|
+
thread = await store.createThread('/tmp/fake-project');
|
|
66
|
+
|
|
67
|
+
registry = new AdapterRegistry();
|
|
68
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
69
|
+
registry.register(createStubAdapter('codex', 'Codex'));
|
|
70
|
+
|
|
71
|
+
controller = new TurnController();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
delete process.env['FIXY_HOME'];
|
|
76
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function makeParams(overrides?: Partial<TurnParams>): TurnParams {
|
|
80
|
+
return {
|
|
81
|
+
thread,
|
|
82
|
+
input: '',
|
|
83
|
+
registry,
|
|
84
|
+
store,
|
|
85
|
+
onLog: () => {},
|
|
86
|
+
signal: AbortSignal.timeout(5000),
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
it('single mention dispatches to the mentioned adapter', async () => {
|
|
94
|
+
await controller.runTurn(makeParams({ input: '@claude hello' }));
|
|
95
|
+
|
|
96
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
97
|
+
|
|
98
|
+
expect(updated.messages).toHaveLength(2);
|
|
99
|
+
|
|
100
|
+
const [userMsg, agentMsg] = updated.messages;
|
|
101
|
+
expect(userMsg.role).toBe('user');
|
|
102
|
+
expect(userMsg.content).toBe('@claude hello');
|
|
103
|
+
|
|
104
|
+
expect(agentMsg.role).toBe('agent');
|
|
105
|
+
expect(agentMsg.agentId).toBe('claude');
|
|
106
|
+
expect(agentMsg.content).toBe('response from claude');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
it('multi mention dispatches sequentially, second sees first response', async () => {
|
|
112
|
+
const capturedMessages: typeof thread.messages = [];
|
|
113
|
+
|
|
114
|
+
registry.unregister('claude');
|
|
115
|
+
registry.unregister('codex');
|
|
116
|
+
|
|
117
|
+
registry.register(
|
|
118
|
+
createStubAdapter('claude', 'Claude', async () => ({
|
|
119
|
+
exitCode: 0,
|
|
120
|
+
signal: null,
|
|
121
|
+
timedOut: false,
|
|
122
|
+
summary: 'claude says hi',
|
|
123
|
+
session: null,
|
|
124
|
+
patches: [],
|
|
125
|
+
warnings: [],
|
|
126
|
+
errorMessage: null,
|
|
127
|
+
})),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
registry.register(
|
|
131
|
+
createStubAdapter('codex', 'Codex', async (ctx) => {
|
|
132
|
+
capturedMessages.push(...ctx.messages);
|
|
133
|
+
return {
|
|
134
|
+
exitCode: 0,
|
|
135
|
+
signal: null,
|
|
136
|
+
timedOut: false,
|
|
137
|
+
summary: 'codex says bye',
|
|
138
|
+
session: null,
|
|
139
|
+
patches: [],
|
|
140
|
+
warnings: [],
|
|
141
|
+
errorMessage: null,
|
|
142
|
+
};
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await controller.runTurn(makeParams({ input: '@claude @codex brainstorm' }));
|
|
147
|
+
|
|
148
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
149
|
+
|
|
150
|
+
expect(updated.messages).toHaveLength(3);
|
|
151
|
+
expect(updated.messages[0].role).toBe('user');
|
|
152
|
+
expect(updated.messages[1].role).toBe('agent');
|
|
153
|
+
expect(updated.messages[1].agentId).toBe('claude');
|
|
154
|
+
expect(updated.messages[2].role).toBe('agent');
|
|
155
|
+
expect(updated.messages[2].agentId).toBe('codex');
|
|
156
|
+
|
|
157
|
+
// Codex's execute received messages that include claude's response.
|
|
158
|
+
const claudeResponseInCodexCtx = capturedMessages.find(
|
|
159
|
+
(m) => m.role === 'agent' && m.agentId === 'claude' && m.content === 'claude says hi',
|
|
160
|
+
);
|
|
161
|
+
expect(claudeResponseInCodexCtx).toBeDefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
it('bare message falls to last agent that spoke', async () => {
|
|
167
|
+
const { randomUUID } = await import('node:crypto');
|
|
168
|
+
|
|
169
|
+
// Manually append an agent message from codex so it is the last agent.
|
|
170
|
+
await store.appendMessage(thread.id, thread.projectRoot, {
|
|
171
|
+
id: randomUUID(),
|
|
172
|
+
createdAt: new Date().toISOString(),
|
|
173
|
+
role: 'agent',
|
|
174
|
+
agentId: 'codex',
|
|
175
|
+
content: 'previous codex response',
|
|
176
|
+
runId: randomUUID(),
|
|
177
|
+
dispatchedTo: [],
|
|
178
|
+
patches: [],
|
|
179
|
+
warnings: [],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Re-read so the thread object held by TurnController has the new message.
|
|
183
|
+
thread = await store.getThread(thread.id, thread.projectRoot);
|
|
184
|
+
|
|
185
|
+
await controller.runTurn(makeParams({ input: 'continue', thread }));
|
|
186
|
+
|
|
187
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
188
|
+
const lastAgentMsg = [...updated.messages].reverse().find((m) => m.role === 'agent');
|
|
189
|
+
|
|
190
|
+
expect(lastAgentMsg?.agentId).toBe('codex');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
it('bare message falls to workerModel when no prior agent', async () => {
|
|
196
|
+
// Thread has no messages yet; workerModel defaults to 'claude'.
|
|
197
|
+
expect(thread.messages).toHaveLength(0);
|
|
198
|
+
expect(thread.workerModel).toBe('claude');
|
|
199
|
+
|
|
200
|
+
await controller.runTurn(makeParams({ input: 'hello' }));
|
|
201
|
+
|
|
202
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
203
|
+
const agentMsg = updated.messages.find((m) => m.role === 'agent');
|
|
204
|
+
|
|
205
|
+
expect(agentMsg?.agentId).toBe('claude');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
it('@fixy /status returns adapter status', async () => {
|
|
211
|
+
await controller.runTurn(makeParams({ input: '@fixy /status' }));
|
|
212
|
+
|
|
213
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
214
|
+
const systemMsg = updated.messages.find((m) => m.role === 'system');
|
|
215
|
+
|
|
216
|
+
expect(systemMsg).toBeDefined();
|
|
217
|
+
expect(systemMsg?.content).toContain('claude');
|
|
218
|
+
expect(systemMsg?.content).toContain('codex');
|
|
219
|
+
expect(systemMsg?.content).toContain('yes');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
it('@fixy /worker changes workerModel', async () => {
|
|
225
|
+
expect(thread.workerModel).toBe('claude');
|
|
226
|
+
|
|
227
|
+
await controller.runTurn(makeParams({ input: '@fixy /worker codex' }));
|
|
228
|
+
|
|
229
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
230
|
+
expect(updated.workerModel).toBe('codex');
|
|
231
|
+
|
|
232
|
+
const systemMsg = updated.messages.find((m) => m.role === 'system');
|
|
233
|
+
expect(systemMsg?.content).toBe('worker set to codex');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
it('unknown mention appends error system message', async () => {
|
|
239
|
+
await controller.runTurn(makeParams({ input: '@unknown do stuff' }));
|
|
240
|
+
|
|
241
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
242
|
+
|
|
243
|
+
const systemMsg = updated.messages.find((m) => m.role === 'system');
|
|
244
|
+
expect(systemMsg?.content).toBe('unknown agent: @unknown');
|
|
245
|
+
|
|
246
|
+
const agentMsgs = updated.messages.filter((m) => m.role === 'agent');
|
|
247
|
+
expect(agentMsgs).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
it('4 mentions rejects with max adapters message', async () => {
|
|
253
|
+
registry.register(createStubAdapter('aider', 'Aider'));
|
|
254
|
+
registry.register(createStubAdapter('gemini', 'Gemini'));
|
|
255
|
+
|
|
256
|
+
await controller.runTurn(makeParams({ input: '@claude @codex @aider @gemini do stuff' }));
|
|
257
|
+
|
|
258
|
+
const updated = await store.getThread(thread.id, thread.projectRoot);
|
|
259
|
+
|
|
260
|
+
const systemMsg = updated.messages.find((m) => m.role === 'system');
|
|
261
|
+
expect(systemMsg?.content).toContain('maximum 3 adapters per turn');
|
|
262
|
+
|
|
263
|
+
const agentMsgs = updated.messages.filter((m) => m.role === 'agent');
|
|
264
|
+
expect(agentMsgs).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
});
|