@doingdev/opencode-claude-manager-plugin 0.1.46 → 0.1.49
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/README.md +29 -31
- package/dist/index.d.ts +1 -1
- package/dist/manager/team-orchestrator.d.ts +50 -0
- package/dist/manager/team-orchestrator.js +360 -0
- package/dist/plugin/agent-hierarchy.d.ts +12 -34
- package/dist/plugin/agent-hierarchy.js +36 -129
- package/dist/plugin/claude-manager.plugin.js +233 -421
- package/dist/plugin/service-factory.d.ts +20 -3
- package/dist/plugin/service-factory.js +46 -1
- package/dist/prompts/registry.d.ts +1 -10
- package/dist/prompts/registry.js +42 -261
- package/dist/src/claude/claude-agent-sdk-adapter.js +2 -1
- package/dist/src/claude/session-live-tailer.js +2 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/manager/git-operations.d.ts +10 -1
- package/dist/src/manager/git-operations.js +18 -3
- package/dist/src/manager/persistent-manager.d.ts +18 -6
- package/dist/src/manager/persistent-manager.js +19 -13
- package/dist/src/manager/session-controller.d.ts +7 -10
- package/dist/src/manager/session-controller.js +12 -62
- package/dist/src/manager/team-orchestrator.d.ts +50 -0
- package/dist/src/manager/team-orchestrator.js +360 -0
- package/dist/src/plugin/agent-hierarchy.d.ts +12 -26
- package/dist/src/plugin/agent-hierarchy.js +36 -99
- package/dist/src/plugin/claude-manager.plugin.js +257 -391
- package/dist/src/plugin/service-factory.d.ts +20 -3
- package/dist/src/plugin/service-factory.js +47 -9
- package/dist/src/prompts/registry.d.ts +1 -10
- package/dist/src/prompts/registry.js +41 -246
- package/dist/src/state/team-state-store.d.ts +17 -0
- package/dist/src/state/team-state-store.js +107 -0
- package/dist/src/team/roster.d.ts +5 -0
- package/dist/src/team/roster.js +38 -0
- package/dist/src/types/contracts.d.ts +55 -13
- package/dist/src/types/contracts.js +1 -1
- package/dist/state/team-state-store.d.ts +17 -0
- package/dist/state/team-state-store.js +107 -0
- package/dist/team/roster.d.ts +5 -0
- package/dist/team/roster.js +38 -0
- package/dist/test/claude-manager.plugin.test.js +55 -280
- package/dist/test/cto-active-team.test.d.ts +1 -0
- package/dist/test/cto-active-team.test.js +52 -0
- package/dist/test/git-operations.test.js +65 -1
- package/dist/test/persistent-manager.test.js +3 -3
- package/dist/test/prompt-registry.test.js +32 -252
- package/dist/test/report-claude-event.test.d.ts +1 -0
- package/dist/test/report-claude-event.test.js +246 -0
- package/dist/test/session-controller.test.js +27 -27
- package/dist/test/team-orchestrator.test.d.ts +1 -0
- package/dist/test/team-orchestrator.test.js +146 -0
- package/dist/test/team-state-store.test.d.ts +1 -0
- package/dist/test/team-state-store.test.js +72 -0
- package/dist/types/contracts.d.ts +54 -3
- package/dist/types/contracts.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for reportClaudeEvent via the real plugin onEvent chain,
|
|
3
|
+
* plus integration tests for second-invocation continuity across
|
|
4
|
+
* clearPluginServices() / new plugin instance.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
11
|
+
import { clearPluginServices, getActiveTeamSession, getOrCreatePluginServices, } from '../src/plugin/service-factory.js';
|
|
12
|
+
import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agent-hierarchy.js';
|
|
13
|
+
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
14
|
+
import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
|
|
15
|
+
function makeContext(worktree, agentId, sessionID) {
|
|
16
|
+
const metadata = vi.fn();
|
|
17
|
+
const ctx = {
|
|
18
|
+
metadata,
|
|
19
|
+
worktree,
|
|
20
|
+
sessionID,
|
|
21
|
+
agent: agentId,
|
|
22
|
+
abort: new AbortController().signal,
|
|
23
|
+
};
|
|
24
|
+
return { metadata, ctx };
|
|
25
|
+
}
|
|
26
|
+
function makeDispatchResult(override = {}) {
|
|
27
|
+
const context = {
|
|
28
|
+
sessionId: 'ses-tom-1',
|
|
29
|
+
totalTurns: 1,
|
|
30
|
+
totalCostUsd: 0.01,
|
|
31
|
+
latestInputTokens: 500,
|
|
32
|
+
latestOutputTokens: 100,
|
|
33
|
+
contextWindowSize: 200_000,
|
|
34
|
+
estimatedContextPercent: 0.5,
|
|
35
|
+
warningLevel: 'ok',
|
|
36
|
+
compactionCount: 0,
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
teamId: 'team-1',
|
|
40
|
+
engineer: 'Tom',
|
|
41
|
+
mode: 'explore',
|
|
42
|
+
sessionId: 'ses-tom-1',
|
|
43
|
+
finalText: 'done',
|
|
44
|
+
turns: 1,
|
|
45
|
+
totalCostUsd: 0.01,
|
|
46
|
+
inputTokens: 500,
|
|
47
|
+
outputTokens: 100,
|
|
48
|
+
contextWindowSize: 200_000,
|
|
49
|
+
context,
|
|
50
|
+
...override,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function executeClaude(plugin, ctx, args = { mode: 'explore', message: 'do work' }) {
|
|
54
|
+
const claudeTool = plugin.tool['claude'];
|
|
55
|
+
return claudeTool.execute(args, ctx);
|
|
56
|
+
}
|
|
57
|
+
// ── reportClaudeEvent via the plugin's onEvent chain ────────────────────────
|
|
58
|
+
describe('reportClaudeEvent — via plugin onEvent chain', () => {
|
|
59
|
+
let tempRoot;
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'report-event-'));
|
|
62
|
+
clearPluginServices();
|
|
63
|
+
});
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
clearPluginServices();
|
|
66
|
+
if (tempRoot)
|
|
67
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Helper: creates a plugin, sets the active CTO team, then stubs
|
|
71
|
+
* dispatchEngineer so it fires the given events before returning.
|
|
72
|
+
*/
|
|
73
|
+
async function setupPlugin(events) {
|
|
74
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
75
|
+
const chatMessage = plugin['chat.message'];
|
|
76
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
77
|
+
const services = getOrCreatePluginServices(tempRoot);
|
|
78
|
+
vi.spyOn(services.orchestrator, 'dispatchEngineer').mockImplementation(async (input) => {
|
|
79
|
+
for (const event of events) {
|
|
80
|
+
await input.onEvent?.(event);
|
|
81
|
+
}
|
|
82
|
+
return makeDispatchResult();
|
|
83
|
+
});
|
|
84
|
+
vi.spyOn(services.orchestrator, 'recordWrapperExchange').mockResolvedValue(undefined);
|
|
85
|
+
return { plugin, services };
|
|
86
|
+
}
|
|
87
|
+
it('surfaces tool name, args, and toolId from a tool_call event', async () => {
|
|
88
|
+
const event = {
|
|
89
|
+
type: 'tool_call',
|
|
90
|
+
sessionId: 'ses-1',
|
|
91
|
+
text: JSON.stringify({ name: 'read', id: 'call-abc', input: { file_path: '/foo.ts' } }),
|
|
92
|
+
};
|
|
93
|
+
const { plugin } = await setupPlugin([event]);
|
|
94
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-1');
|
|
95
|
+
await executeClaude(plugin, ctx);
|
|
96
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('→'))?.[0];
|
|
97
|
+
expect(call).toBeDefined();
|
|
98
|
+
expect(call.title).toBe('⚡ Tom → read');
|
|
99
|
+
expect(call.metadata.toolName).toBe('read');
|
|
100
|
+
expect(call.metadata.toolId).toBe('call-abc');
|
|
101
|
+
expect(call.metadata.toolArgs).toEqual({ file_path: '/foo.ts' });
|
|
102
|
+
expect(call.metadata.sessionId).toBe('ses-1');
|
|
103
|
+
expect(call.metadata.engineer).toBe('Tom');
|
|
104
|
+
});
|
|
105
|
+
it('double-decodes a JSON-string input (tool input serialised twice)', async () => {
|
|
106
|
+
// The SDK adapter may serialize `input` as a JSON string inside the outer JSON on
|
|
107
|
+
// some tool calls. The handler should parse the inner string into an object.
|
|
108
|
+
const event = {
|
|
109
|
+
type: 'tool_call',
|
|
110
|
+
sessionId: 'ses-2',
|
|
111
|
+
text: JSON.stringify({
|
|
112
|
+
name: 'bash',
|
|
113
|
+
id: 'call-def',
|
|
114
|
+
input: JSON.stringify({ command: 'ls -la' }),
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
const { plugin } = await setupPlugin([event]);
|
|
118
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-2');
|
|
119
|
+
await executeClaude(plugin, ctx);
|
|
120
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('→'))?.[0];
|
|
121
|
+
expect(call.title).toBe('⚡ Tom → bash');
|
|
122
|
+
expect(call.metadata.toolArgs).toEqual({ command: 'ls -la' });
|
|
123
|
+
});
|
|
124
|
+
it('falls back to generic title and omits tool fields when event.text is not JSON', async () => {
|
|
125
|
+
const event = {
|
|
126
|
+
type: 'tool_call',
|
|
127
|
+
sessionId: 'ses-3',
|
|
128
|
+
text: 'not-json-at-all',
|
|
129
|
+
};
|
|
130
|
+
const { plugin } = await setupPlugin([event]);
|
|
131
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-3');
|
|
132
|
+
await executeClaude(plugin, ctx);
|
|
133
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('is using Claude Code tools'))?.[0];
|
|
134
|
+
expect(call).toBeDefined();
|
|
135
|
+
expect(call.title).toBe('⚡ Tom is using Claude Code tools');
|
|
136
|
+
expect(call.metadata).not.toHaveProperty('toolName');
|
|
137
|
+
expect(call.metadata).not.toHaveProperty('toolId');
|
|
138
|
+
expect(call.metadata).not.toHaveProperty('toolArgs');
|
|
139
|
+
});
|
|
140
|
+
it('falls back to generic title when parsed JSON has no name field', async () => {
|
|
141
|
+
const event = {
|
|
142
|
+
type: 'tool_call',
|
|
143
|
+
sessionId: 'ses-4',
|
|
144
|
+
text: JSON.stringify({ id: 'call-xyz', input: {} }),
|
|
145
|
+
};
|
|
146
|
+
const { plugin } = await setupPlugin([event]);
|
|
147
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.John, 'wrapper-4');
|
|
148
|
+
await executeClaude(plugin, ctx);
|
|
149
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('is using Claude Code tools'))?.[0];
|
|
150
|
+
expect(call).toBeDefined();
|
|
151
|
+
expect(call.metadata).not.toHaveProperty('toolName');
|
|
152
|
+
});
|
|
153
|
+
it('includes toolArgs when input is an empty object', async () => {
|
|
154
|
+
const event = {
|
|
155
|
+
type: 'tool_call',
|
|
156
|
+
sessionId: 'ses-5',
|
|
157
|
+
text: JSON.stringify({ name: 'git_status', id: 'call-ghi', input: {} }),
|
|
158
|
+
};
|
|
159
|
+
const { plugin } = await setupPlugin([event]);
|
|
160
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Maya, 'wrapper-5');
|
|
161
|
+
await executeClaude(plugin, ctx);
|
|
162
|
+
const call = metadata.mock.calls.find(([c]) => c?.title?.includes('→'))?.[0];
|
|
163
|
+
expect(call.title).toBe('⚡ Maya → git_status');
|
|
164
|
+
expect(call.metadata.toolArgs).toEqual({});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
// ── Second-invocation continuity ─────────────────────────────────────────────
|
|
168
|
+
describe('second invocation continuity', () => {
|
|
169
|
+
let tempRoot;
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'continuity-'));
|
|
172
|
+
clearPluginServices();
|
|
173
|
+
});
|
|
174
|
+
afterEach(async () => {
|
|
175
|
+
clearPluginServices();
|
|
176
|
+
if (tempRoot)
|
|
177
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
it('wrapper memory is injected after clearPluginServices and a new plugin instance', async () => {
|
|
180
|
+
// ── Phase 1: first task via orchestrator (no real SDK needed) ──────────
|
|
181
|
+
const store = new TeamStateStore();
|
|
182
|
+
await store.setActiveTeam(tempRoot, 'cto-1');
|
|
183
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', []);
|
|
184
|
+
await orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1');
|
|
185
|
+
await orchestrator.recordWrapperExchange(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1', 'explore', 'Investigate the auth flow', 'Found two race conditions in the token refresh path.');
|
|
186
|
+
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
187
|
+
clearPluginServices();
|
|
188
|
+
// ── Phase 3: new plugin instance, new CTO session ──────────────────────
|
|
189
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
190
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
191
|
+
const systemTransform2 = plugin2['experimental.chat.system.transform'];
|
|
192
|
+
// New CTO session must adopt the persisted team, not create a new one.
|
|
193
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
|
|
194
|
+
expect(getActiveTeamSession(tempRoot)).toBe('cto-1');
|
|
195
|
+
// Tom's new wrapper session must be registered under the persisted team.
|
|
196
|
+
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
197
|
+
// Transform fires (after chat.message has registered the session mapping).
|
|
198
|
+
const output = { system: [] };
|
|
199
|
+
await systemTransform2({ sessionID: 'wrapper-tom-2', model: 'claude-sonnet-4-6' }, output);
|
|
200
|
+
expect(output.system).toHaveLength(1);
|
|
201
|
+
expect(output.system[0]).toContain('Persistent wrapper memory for Tom');
|
|
202
|
+
expect(output.system[0]).toContain('Investigate the auth flow');
|
|
203
|
+
expect(output.system[0]).toContain('Found two race conditions');
|
|
204
|
+
});
|
|
205
|
+
it('existing engineer Claude session is resumed on second invocation', async () => {
|
|
206
|
+
// ── Phase 1: pre-seed Tom with a claudeSessionId ───────────────────────
|
|
207
|
+
const store = new TeamStateStore();
|
|
208
|
+
await store.setActiveTeam(tempRoot, 'cto-1');
|
|
209
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', []);
|
|
210
|
+
await orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
211
|
+
await store.updateTeam(tempRoot, 'cto-1', (team) => ({
|
|
212
|
+
...team,
|
|
213
|
+
engineers: team.engineers.map((e) => e.name === 'Tom' ? { ...e, claudeSessionId: 'ses-tom-persisted' } : e),
|
|
214
|
+
}));
|
|
215
|
+
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
216
|
+
clearPluginServices();
|
|
217
|
+
// ── Phase 3: new plugin, new CTO, engineer runs second task ───────────
|
|
218
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
219
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
220
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
|
|
221
|
+
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
222
|
+
const services2 = getOrCreatePluginServices(tempRoot);
|
|
223
|
+
// Mock at the session level so dispatchEngineer runs its real logic
|
|
224
|
+
// (reads claudeSessionId, passes resumeSessionId to runTask).
|
|
225
|
+
const runTask = vi.spyOn(services2.sessions, 'runTask').mockResolvedValue({
|
|
226
|
+
sessionId: 'ses-tom-persisted',
|
|
227
|
+
events: [],
|
|
228
|
+
finalText: 'resumed result',
|
|
229
|
+
turns: 2,
|
|
230
|
+
totalCostUsd: 0.02,
|
|
231
|
+
inputTokens: 1000,
|
|
232
|
+
outputTokens: 200,
|
|
233
|
+
contextWindowSize: 200_000,
|
|
234
|
+
});
|
|
235
|
+
vi.spyOn(services2.orchestrator, 'recordWrapperExchange').mockResolvedValue(undefined);
|
|
236
|
+
const { ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.Tom, 'wrapper-tom-2');
|
|
237
|
+
await executeClaude(plugin2, ctx);
|
|
238
|
+
// dispatchEngineer should have read claudeSessionId='ses-tom-persisted' from
|
|
239
|
+
// the team store and forwarded it as resumeSessionId.
|
|
240
|
+
expect(runTask).toHaveBeenCalledOnce();
|
|
241
|
+
expect(runTask.mock.calls[0]?.[0]).toMatchObject({
|
|
242
|
+
resumeSessionId: 'ses-tom-persisted',
|
|
243
|
+
systemPrompt: undefined, // no new system prompt when resuming
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -25,9 +25,9 @@ describe('SessionController', () => {
|
|
|
25
25
|
it('creates a new session on first sendMessage', async () => {
|
|
26
26
|
const adapter = createMockAdapter();
|
|
27
27
|
const tracker = new ContextTracker();
|
|
28
|
-
const controller = new SessionController(adapter, tracker, 'test prompt');
|
|
28
|
+
const controller = new SessionController(adapter, tracker, 'test prompt', 'test', '/tmp');
|
|
29
29
|
expect(controller.isActive).toBe(false);
|
|
30
|
-
const result = await controller.sendMessage('
|
|
30
|
+
const result = await controller.sendMessage('hello');
|
|
31
31
|
expect(controller.isActive).toBe(true);
|
|
32
32
|
expect(controller.sessionId).toBe('ses_test');
|
|
33
33
|
expect(result.finalText).toBe('Done.');
|
|
@@ -39,17 +39,17 @@ describe('SessionController', () => {
|
|
|
39
39
|
it('sends settingSources as user-only', async () => {
|
|
40
40
|
const adapter = createMockAdapter();
|
|
41
41
|
const tracker = new ContextTracker();
|
|
42
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
43
|
-
await controller.sendMessage('
|
|
42
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
43
|
+
await controller.sendMessage('hello');
|
|
44
44
|
const call = adapter.runSession.mock.calls[0][0];
|
|
45
45
|
expect(call.settingSources).toEqual(['user']);
|
|
46
46
|
});
|
|
47
47
|
it('resumes session on subsequent sends', async () => {
|
|
48
48
|
const adapter = createMockAdapter([{}, {}]);
|
|
49
49
|
const tracker = new ContextTracker();
|
|
50
|
-
const controller = new SessionController(adapter, tracker, 'test prompt');
|
|
51
|
-
await controller.sendMessage('
|
|
52
|
-
await controller.sendMessage('
|
|
50
|
+
const controller = new SessionController(adapter, tracker, 'test prompt', 'test', '/tmp');
|
|
51
|
+
await controller.sendMessage('first');
|
|
52
|
+
await controller.sendMessage('second');
|
|
53
53
|
const secondCall = adapter.runSession.mock.calls[1][0];
|
|
54
54
|
expect(secondCall.resumeSessionId).toBe('ses_test');
|
|
55
55
|
expect(secondCall.systemPrompt).toBeUndefined();
|
|
@@ -57,8 +57,8 @@ describe('SessionController', () => {
|
|
|
57
57
|
it('updates context tracker on each message', async () => {
|
|
58
58
|
const adapter = createMockAdapter([{ turns: 3, totalCostUsd: 0.05, inputTokens: 50_000 }]);
|
|
59
59
|
const tracker = new ContextTracker();
|
|
60
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
61
|
-
await controller.sendMessage('
|
|
60
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
61
|
+
await controller.sendMessage('task');
|
|
62
62
|
const snap = controller.getContextSnapshot();
|
|
63
63
|
expect(snap.totalTurns).toBe(3);
|
|
64
64
|
expect(snap.totalCostUsd).toBe(0.05);
|
|
@@ -68,10 +68,10 @@ describe('SessionController', () => {
|
|
|
68
68
|
it('clears session and resets context', async () => {
|
|
69
69
|
const adapter = createMockAdapter();
|
|
70
70
|
const tracker = new ContextTracker();
|
|
71
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
72
|
-
await controller.sendMessage('
|
|
71
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
72
|
+
await controller.sendMessage('task');
|
|
73
73
|
expect(controller.isActive).toBe(true);
|
|
74
|
-
const clearedId = await controller.clearSession(
|
|
74
|
+
const clearedId = await controller.clearSession();
|
|
75
75
|
expect(clearedId).toBe('ses_test');
|
|
76
76
|
expect(controller.isActive).toBe(false);
|
|
77
77
|
expect(controller.sessionId).toBeNull();
|
|
@@ -82,9 +82,9 @@ describe('SessionController', () => {
|
|
|
82
82
|
it('sends /compact to current session', async () => {
|
|
83
83
|
const adapter = createMockAdapter([{}, {}]);
|
|
84
84
|
const tracker = new ContextTracker();
|
|
85
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
86
|
-
await controller.sendMessage('
|
|
87
|
-
await controller.compactSession(
|
|
85
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
86
|
+
await controller.sendMessage('task');
|
|
87
|
+
await controller.compactSession();
|
|
88
88
|
const compactCall = adapter.runSession.mock.calls[1][0];
|
|
89
89
|
expect(compactCall.prompt).toBe('/compact');
|
|
90
90
|
expect(compactCall.resumeSessionId).toBe('ses_test');
|
|
@@ -92,14 +92,14 @@ describe('SessionController', () => {
|
|
|
92
92
|
it('throws when compacting without active session', async () => {
|
|
93
93
|
const adapter = createMockAdapter();
|
|
94
94
|
const tracker = new ContextTracker();
|
|
95
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
96
|
-
await expect(controller.compactSession(
|
|
95
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
96
|
+
await expect(controller.compactSession()).rejects.toThrow('No active session to compact');
|
|
97
97
|
});
|
|
98
98
|
it('threads effort through to the SDK input', async () => {
|
|
99
99
|
const adapter = createMockAdapter();
|
|
100
100
|
const tracker = new ContextTracker();
|
|
101
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
102
|
-
await controller.sendMessage('
|
|
101
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
102
|
+
await controller.sendMessage('hard task', { effort: 'max' });
|
|
103
103
|
const call = adapter.runSession.mock.calls[0][0];
|
|
104
104
|
expect(call.effort).toBe('max');
|
|
105
105
|
});
|
|
@@ -111,8 +111,8 @@ describe('SessionController', () => {
|
|
|
111
111
|
it('defaults to free mode with acceptEdits permissionMode', async () => {
|
|
112
112
|
const adapter = createMockAdapter();
|
|
113
113
|
const tracker = new ContextTracker();
|
|
114
|
-
const controller = new SessionController(adapter, tracker, 'test', modePrefixes);
|
|
115
|
-
await controller.sendMessage('
|
|
114
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp', modePrefixes);
|
|
115
|
+
await controller.sendMessage('do something');
|
|
116
116
|
const call = adapter.runSession.mock.calls[0][0];
|
|
117
117
|
expect(call.permissionMode).toBe('acceptEdits');
|
|
118
118
|
expect(call.prompt).toBe('do something');
|
|
@@ -120,8 +120,8 @@ describe('SessionController', () => {
|
|
|
120
120
|
it('sets permissionMode to plan and prepends prefix in plan mode', async () => {
|
|
121
121
|
const adapter = createMockAdapter();
|
|
122
122
|
const tracker = new ContextTracker();
|
|
123
|
-
const controller = new SessionController(adapter, tracker, 'test', modePrefixes);
|
|
124
|
-
await controller.sendMessage('
|
|
123
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp', modePrefixes);
|
|
124
|
+
await controller.sendMessage('analyze this', { mode: 'plan' });
|
|
125
125
|
const call = adapter.runSession.mock.calls[0][0];
|
|
126
126
|
expect(call.permissionMode).toBe('plan');
|
|
127
127
|
expect(call.prompt).toBe('[PLAN MODE] Read-only planning.\n\nanalyze this');
|
|
@@ -129,8 +129,8 @@ describe('SessionController', () => {
|
|
|
129
129
|
it('explicit free mode uses acceptEdits and no prefix', async () => {
|
|
130
130
|
const adapter = createMockAdapter();
|
|
131
131
|
const tracker = new ContextTracker();
|
|
132
|
-
const controller = new SessionController(adapter, tracker, 'test', modePrefixes);
|
|
133
|
-
await controller.sendMessage('
|
|
132
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp', modePrefixes);
|
|
133
|
+
await controller.sendMessage('build it', { mode: 'free' });
|
|
134
134
|
const call = adapter.runSession.mock.calls[0][0];
|
|
135
135
|
expect(call.permissionMode).toBe('acceptEdits');
|
|
136
136
|
expect(call.prompt).toBe('build it');
|
|
@@ -138,8 +138,8 @@ describe('SessionController', () => {
|
|
|
138
138
|
it('works without modePrefixes constructor arg (backward compat)', async () => {
|
|
139
139
|
const adapter = createMockAdapter();
|
|
140
140
|
const tracker = new ContextTracker();
|
|
141
|
-
const controller = new SessionController(adapter, tracker, 'test');
|
|
142
|
-
await controller.sendMessage('
|
|
141
|
+
const controller = new SessionController(adapter, tracker, 'test', 'test', '/tmp');
|
|
142
|
+
await controller.sendMessage('hello', { mode: 'plan' });
|
|
143
143
|
const call = adapter.runSession.mock.calls[0][0];
|
|
144
144
|
expect(call.permissionMode).toBe('plan');
|
|
145
145
|
// Empty prefix defaults — prompt should be unchanged
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
|
|
6
|
+
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
7
|
+
describe('TeamOrchestrator', () => {
|
|
8
|
+
let tempRoot;
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
if (tempRoot) {
|
|
11
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
it('dispatches work to a named engineer and persists the Claude session', async () => {
|
|
15
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
16
|
+
const runTask = vi
|
|
17
|
+
.fn()
|
|
18
|
+
.mockResolvedValueOnce({
|
|
19
|
+
sessionId: 'ses_tom',
|
|
20
|
+
events: [{ type: 'result', text: 'done', turns: 1, totalCostUsd: 0.02 }],
|
|
21
|
+
finalText: 'Done.',
|
|
22
|
+
turns: 1,
|
|
23
|
+
totalCostUsd: 0.02,
|
|
24
|
+
inputTokens: 1000,
|
|
25
|
+
outputTokens: 200,
|
|
26
|
+
contextWindowSize: 200_000,
|
|
27
|
+
})
|
|
28
|
+
.mockResolvedValueOnce({
|
|
29
|
+
sessionId: 'ses_tom',
|
|
30
|
+
events: [{ type: 'result', text: 'done again', turns: 2, totalCostUsd: 0.03 }],
|
|
31
|
+
finalText: 'Done again.',
|
|
32
|
+
turns: 2,
|
|
33
|
+
totalCostUsd: 0.03,
|
|
34
|
+
inputTokens: 2000,
|
|
35
|
+
outputTokens: 300,
|
|
36
|
+
contextWindowSize: 200_000,
|
|
37
|
+
});
|
|
38
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
|
|
39
|
+
const first = await orchestrator.dispatchEngineer({
|
|
40
|
+
teamId: 'team-1',
|
|
41
|
+
cwd: tempRoot,
|
|
42
|
+
engineer: 'Tom',
|
|
43
|
+
mode: 'explore',
|
|
44
|
+
message: 'Investigate the auth flow',
|
|
45
|
+
});
|
|
46
|
+
const second = await orchestrator.dispatchEngineer({
|
|
47
|
+
teamId: 'team-1',
|
|
48
|
+
cwd: tempRoot,
|
|
49
|
+
engineer: 'Tom',
|
|
50
|
+
mode: 'implement',
|
|
51
|
+
message: 'Implement the chosen fix',
|
|
52
|
+
});
|
|
53
|
+
expect(first.sessionId).toBe('ses_tom');
|
|
54
|
+
expect(second.sessionId).toBe('ses_tom');
|
|
55
|
+
expect(runTask.mock.calls[0]?.[0]).toMatchObject({
|
|
56
|
+
systemPrompt: expect.stringContaining('Assigned engineer: Tom.'),
|
|
57
|
+
resumeSessionId: undefined,
|
|
58
|
+
permissionMode: 'plan',
|
|
59
|
+
});
|
|
60
|
+
expect(runTask.mock.calls[1]?.[0]).toMatchObject({
|
|
61
|
+
systemPrompt: undefined,
|
|
62
|
+
resumeSessionId: 'ses_tom',
|
|
63
|
+
permissionMode: 'acceptEdits',
|
|
64
|
+
});
|
|
65
|
+
const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
66
|
+
expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
|
|
67
|
+
claudeSessionId: 'ses_tom',
|
|
68
|
+
busy: false,
|
|
69
|
+
lastMode: 'implement',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
it('rejects work when the same engineer is already busy', async () => {
|
|
73
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
74
|
+
const store = new TeamStateStore('.state');
|
|
75
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
|
|
76
|
+
const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
77
|
+
await store.saveTeam({
|
|
78
|
+
...team,
|
|
79
|
+
engineers: team.engineers.map((engineer) => engineer.name === 'Tom'
|
|
80
|
+
? {
|
|
81
|
+
...engineer,
|
|
82
|
+
busy: true,
|
|
83
|
+
}
|
|
84
|
+
: engineer),
|
|
85
|
+
});
|
|
86
|
+
await expect(orchestrator.dispatchEngineer({
|
|
87
|
+
teamId: 'team-1',
|
|
88
|
+
cwd: tempRoot,
|
|
89
|
+
engineer: 'Tom',
|
|
90
|
+
mode: 'explore',
|
|
91
|
+
message: 'Investigate again',
|
|
92
|
+
})).rejects.toThrow('Tom is already working on another assignment.');
|
|
93
|
+
});
|
|
94
|
+
it('creates two drafts and synthesizes them into one plan', async () => {
|
|
95
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
96
|
+
const runTask = vi
|
|
97
|
+
.fn()
|
|
98
|
+
.mockResolvedValueOnce({
|
|
99
|
+
sessionId: 'ses_tom',
|
|
100
|
+
events: [],
|
|
101
|
+
finalText: '## Objective\nLead plan',
|
|
102
|
+
})
|
|
103
|
+
.mockResolvedValueOnce({
|
|
104
|
+
sessionId: 'ses_maya',
|
|
105
|
+
events: [],
|
|
106
|
+
finalText: '## Objective\nChallenger plan',
|
|
107
|
+
})
|
|
108
|
+
.mockResolvedValueOnce({
|
|
109
|
+
sessionId: undefined,
|
|
110
|
+
events: [],
|
|
111
|
+
finalText: '## Synthesis\nCombined plan\n## Recommended Question\nShould we migrate now?\n## Recommended Answer\nNo, defer it.',
|
|
112
|
+
});
|
|
113
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
|
|
114
|
+
const result = await orchestrator.planWithTeam({
|
|
115
|
+
teamId: 'team-1',
|
|
116
|
+
cwd: tempRoot,
|
|
117
|
+
request: 'Plan the billing refactor',
|
|
118
|
+
leadEngineer: 'Tom',
|
|
119
|
+
challengerEngineer: 'Maya',
|
|
120
|
+
});
|
|
121
|
+
expect(result.drafts).toHaveLength(2);
|
|
122
|
+
expect(result.synthesis).toBe('Combined plan');
|
|
123
|
+
expect(result.recommendedQuestion).toBe('Should we migrate now?');
|
|
124
|
+
expect(result.recommendedAnswer).toBe('No, defer it.');
|
|
125
|
+
expect(runTask).toHaveBeenCalledTimes(3);
|
|
126
|
+
});
|
|
127
|
+
it('persists wrapper session memory for an engineer', async () => {
|
|
128
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
129
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
|
|
130
|
+
await orchestrator.recordWrapperSession(tempRoot, 'team-1', 'Tom', 'wrapper-tom');
|
|
131
|
+
await orchestrator.recordWrapperExchange(tempRoot, 'team-1', 'Tom', 'wrapper-tom', 'explore', 'Investigate the auth flow and compare approaches', 'The auth flow uses one shared validator and the cookie refresh path is the main risk.');
|
|
132
|
+
const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
133
|
+
expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
|
|
134
|
+
wrapperSessionId: 'wrapper-tom',
|
|
135
|
+
lastMode: 'explore',
|
|
136
|
+
});
|
|
137
|
+
const wrapperContext = await orchestrator.getWrapperSystemContext(tempRoot, 'team-1', 'Tom');
|
|
138
|
+
expect(wrapperContext).toContain('Persistent wrapper memory for Tom');
|
|
139
|
+
expect(wrapperContext).toContain('assignment [explore]');
|
|
140
|
+
expect(wrapperContext).toContain('result [explore]');
|
|
141
|
+
await expect(orchestrator.findTeamByWrapperSession(tempRoot, 'wrapper-tom')).resolves.toEqual({
|
|
142
|
+
teamId: 'team-1',
|
|
143
|
+
engineer: 'Tom',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
6
|
+
import { createEmptyTeamRecord } from '../src/team/roster.js';
|
|
7
|
+
describe('TeamStateStore', () => {
|
|
8
|
+
let tempRoot;
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
if (tempRoot) {
|
|
11
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
it('saves and reads a team record', async () => {
|
|
15
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
16
|
+
const store = new TeamStateStore('.state');
|
|
17
|
+
const team = createEmptyTeamRecord('team-1', tempRoot);
|
|
18
|
+
await store.saveTeam(team);
|
|
19
|
+
await expect(store.getTeam(tempRoot, 'team-1')).resolves.toEqual(team);
|
|
20
|
+
});
|
|
21
|
+
it('updates one engineer inside a team record', async () => {
|
|
22
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
23
|
+
const store = new TeamStateStore('.state');
|
|
24
|
+
const team = createEmptyTeamRecord('team-1', tempRoot);
|
|
25
|
+
await store.saveTeam(team);
|
|
26
|
+
const updated = await store.updateTeam(tempRoot, 'team-1', (existing) => ({
|
|
27
|
+
...existing,
|
|
28
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
29
|
+
engineers: existing.engineers.map((engineer) => engineer.name === 'Tom'
|
|
30
|
+
? {
|
|
31
|
+
...engineer,
|
|
32
|
+
claudeSessionId: 'ses_tom',
|
|
33
|
+
lastTaskSummary: 'Plan the feature',
|
|
34
|
+
}
|
|
35
|
+
: engineer),
|
|
36
|
+
}));
|
|
37
|
+
expect(updated.engineers.find((engineer) => engineer.name === 'Tom')?.claudeSessionId).toBe('ses_tom');
|
|
38
|
+
expect(updated.engineers.find((engineer) => engineer.name === 'John')?.claudeSessionId).toBe(null);
|
|
39
|
+
});
|
|
40
|
+
it('lists teams newest first', async () => {
|
|
41
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
42
|
+
const store = new TeamStateStore('.state');
|
|
43
|
+
const older = createEmptyTeamRecord('older', tempRoot);
|
|
44
|
+
older.updatedAt = '2026-01-01T00:00:00.000Z';
|
|
45
|
+
const newer = createEmptyTeamRecord('newer', tempRoot);
|
|
46
|
+
newer.updatedAt = '2026-01-02T00:00:00.000Z';
|
|
47
|
+
await store.saveTeam(older);
|
|
48
|
+
await store.saveTeam(newer);
|
|
49
|
+
await expect(store.listTeams(tempRoot)).resolves.toMatchObject([
|
|
50
|
+
{ id: 'newer' },
|
|
51
|
+
{ id: 'older' },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
it('returns null for active team when no active-team.json exists', async () => {
|
|
55
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
56
|
+
const store = new TeamStateStore('.state');
|
|
57
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it('persists and reads back the active team ID', async () => {
|
|
60
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
61
|
+
const store = new TeamStateStore('.state');
|
|
62
|
+
await store.setActiveTeam(tempRoot, 'team-abc');
|
|
63
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-abc');
|
|
64
|
+
});
|
|
65
|
+
it('overwrites the active team ID on subsequent writes', async () => {
|
|
66
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
67
|
+
const store = new TeamStateStore('.state');
|
|
68
|
+
await store.setActiveTeam(tempRoot, 'team-first');
|
|
69
|
+
await store.setActiveTeam(tempRoot, 'team-second');
|
|
70
|
+
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-second');
|
|
71
|
+
});
|
|
72
|
+
});
|