@doingdev/opencode-claude-manager-plugin 0.1.57 → 0.1.59
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/manager/team-orchestrator.d.ts +3 -2
- package/dist/manager/team-orchestrator.js +32 -9
- package/dist/plugin/agent-hierarchy.d.ts +1 -54
- package/dist/plugin/agent-hierarchy.js +2 -123
- package/dist/plugin/agents/browser-qa.d.ts +14 -0
- package/dist/plugin/agents/browser-qa.js +27 -0
- package/dist/plugin/agents/common.d.ts +37 -0
- package/dist/plugin/agents/common.js +59 -0
- package/dist/plugin/agents/cto.d.ts +9 -0
- package/dist/plugin/agents/cto.js +39 -0
- package/dist/plugin/agents/engineers.d.ts +9 -0
- package/dist/plugin/agents/engineers.js +11 -0
- package/dist/plugin/agents/index.d.ts +6 -0
- package/dist/plugin/agents/index.js +5 -0
- package/dist/plugin/agents/team-planner.d.ts +10 -0
- package/dist/plugin/agents/team-planner.js +23 -0
- package/dist/plugin/claude-manager.plugin.js +97 -47
- package/dist/plugin/service-factory.d.ts +8 -7
- package/dist/plugin/service-factory.js +18 -19
- package/dist/prompts/registry.js +37 -2
- package/dist/src/manager/team-orchestrator.d.ts +3 -2
- package/dist/src/manager/team-orchestrator.js +32 -9
- package/dist/src/plugin/agent-hierarchy.d.ts +1 -54
- package/dist/src/plugin/agent-hierarchy.js +2 -123
- package/dist/src/plugin/agents/browser-qa.d.ts +14 -0
- package/dist/src/plugin/agents/browser-qa.js +27 -0
- package/dist/src/plugin/agents/common.d.ts +37 -0
- package/dist/src/plugin/agents/common.js +59 -0
- package/dist/src/plugin/agents/cto.d.ts +9 -0
- package/dist/src/plugin/agents/cto.js +39 -0
- package/dist/src/plugin/agents/engineers.d.ts +9 -0
- package/dist/src/plugin/agents/engineers.js +11 -0
- package/dist/src/plugin/agents/index.d.ts +6 -0
- package/dist/src/plugin/agents/index.js +5 -0
- package/dist/src/plugin/agents/team-planner.d.ts +10 -0
- package/dist/src/plugin/agents/team-planner.js +23 -0
- package/dist/src/plugin/claude-manager.plugin.js +97 -47
- package/dist/src/plugin/service-factory.d.ts +8 -7
- package/dist/src/plugin/service-factory.js +18 -19
- package/dist/src/prompts/registry.js +37 -2
- package/dist/src/state/team-state-store.d.ts +0 -3
- package/dist/src/state/team-state-store.js +0 -22
- package/dist/src/team/roster.d.ts +3 -2
- package/dist/src/team/roster.js +2 -1
- package/dist/src/types/contracts.d.ts +25 -1
- package/dist/src/types/contracts.js +2 -1
- package/dist/state/team-state-store.d.ts +0 -3
- package/dist/state/team-state-store.js +0 -22
- package/dist/team/roster.d.ts +3 -2
- package/dist/team/roster.js +2 -1
- package/dist/test/claude-manager.plugin.test.js +60 -0
- package/dist/test/cto-active-team.test.js +176 -29
- package/dist/test/prompt-registry.test.js +15 -0
- package/dist/test/report-claude-event.test.js +60 -15
- package/dist/test/team-orchestrator.test.js +47 -8
- package/dist/test/team-state-store.test.js +0 -18
- package/dist/types/contracts.d.ts +25 -1
- package/dist/types/contracts.js +2 -1
- package/package.json +1 -1
|
@@ -47,6 +47,8 @@ describe('ClaudeManagerPlugin', () => {
|
|
|
47
47
|
sara: 'allow',
|
|
48
48
|
Alex: 'allow',
|
|
49
49
|
alex: 'allow',
|
|
50
|
+
BrowserQA: 'allow',
|
|
51
|
+
'browser-qa': 'allow',
|
|
50
52
|
'team-planner': 'allow',
|
|
51
53
|
});
|
|
52
54
|
});
|
|
@@ -209,4 +211,62 @@ describe('Agent ID normalization and lookup helpers', () => {
|
|
|
209
211
|
// CTO should NOT have direct access to plan_with_team (must delegate to team-planner)
|
|
210
212
|
expect(ctoPermissions['plan_with_team']).not.toBe('allow');
|
|
211
213
|
});
|
|
214
|
+
it('browser-qa agent is registered with correct permissions', async () => {
|
|
215
|
+
const plugin = await ClaudeManagerPlugin({
|
|
216
|
+
worktree: '/tmp/project',
|
|
217
|
+
});
|
|
218
|
+
const config = {};
|
|
219
|
+
await plugin.config?.(config);
|
|
220
|
+
const agents = (config.agent ?? {});
|
|
221
|
+
const { AGENT_BROWSER_QA } = await import('../src/plugin/agent-hierarchy.js');
|
|
222
|
+
const browserQa = agents[AGENT_BROWSER_QA];
|
|
223
|
+
expect(browserQa).toBeDefined();
|
|
224
|
+
expect(browserQa.permission).toBeDefined();
|
|
225
|
+
// BrowserQA should have access to claude tool
|
|
226
|
+
expect(browserQa.permission?.['claude']).toBe('allow');
|
|
227
|
+
});
|
|
228
|
+
it('browser-qa session prompt allows Playwright skill/command', async () => {
|
|
229
|
+
const { managerPromptRegistry } = await import('../src/prompts/registry.js');
|
|
230
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).toContain('Playwright skill');
|
|
231
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).toContain('real browser');
|
|
232
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).toContain('Allowed tools');
|
|
233
|
+
});
|
|
234
|
+
it('browser-qa agent prompt mentions PLAYWRIGHT_UNAVAILABLE sentinel', async () => {
|
|
235
|
+
const { managerPromptRegistry } = await import('../src/prompts/registry.js');
|
|
236
|
+
expect(managerPromptRegistry.browserQaAgentPrompt).toContain('PLAYWRIGHT_UNAVAILABLE');
|
|
237
|
+
expect(managerPromptRegistry.browserQaAgentPrompt).toContain('Playwright');
|
|
238
|
+
});
|
|
239
|
+
it('browser-qa agent is registered in ENGINEER_AGENT_IDS', async () => {
|
|
240
|
+
const { AGENT_BROWSER_QA, ENGINEER_AGENT_IDS } = await import('../src/plugin/agent-hierarchy.js');
|
|
241
|
+
// BrowserQA should be in the engineer IDs
|
|
242
|
+
const engineerIds = Object.values(ENGINEER_AGENT_IDS);
|
|
243
|
+
expect(engineerIds).toContain('browser-qa');
|
|
244
|
+
expect(ENGINEER_AGENT_IDS['BrowserQA']).toBe(AGENT_BROWSER_QA);
|
|
245
|
+
});
|
|
246
|
+
it('browser-qa agent config has restricted write access', async () => {
|
|
247
|
+
const plugin = await ClaudeManagerPlugin({
|
|
248
|
+
worktree: '/tmp/project',
|
|
249
|
+
});
|
|
250
|
+
const config = {};
|
|
251
|
+
await plugin.config?.(config);
|
|
252
|
+
const agents = (config.agent ?? {});
|
|
253
|
+
const { AGENT_BROWSER_QA } = await import('../src/plugin/agent-hierarchy.js');
|
|
254
|
+
const browserQa = agents[AGENT_BROWSER_QA];
|
|
255
|
+
expect(browserQa).toBeDefined();
|
|
256
|
+
// Should allow claude tool (engineers can call claude)
|
|
257
|
+
expect(browserQa.permission?.['claude']).toBe('allow');
|
|
258
|
+
// Should deny most other tools
|
|
259
|
+
expect(browserQa.permission?.['read']).toBeUndefined(); // Falls back to deny
|
|
260
|
+
expect(browserQa.permission?.['write']).toBeUndefined(); // Falls back to deny
|
|
261
|
+
});
|
|
262
|
+
it('browserqa agent uses same engineer dispatch path', async () => {
|
|
263
|
+
const plugin = await ClaudeManagerPlugin({
|
|
264
|
+
worktree: '/tmp/project',
|
|
265
|
+
});
|
|
266
|
+
const config = {};
|
|
267
|
+
await plugin.config?.(config);
|
|
268
|
+
const agents = (config.agent ?? {});
|
|
269
|
+
// BrowserQA should be configured as an agent
|
|
270
|
+
expect(agents['browser-qa']).toBeDefined();
|
|
271
|
+
});
|
|
212
272
|
});
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the session-per-team CTO model:
|
|
3
|
+
* - Each CTO session ID is its own team ID (no shared repo-global state).
|
|
4
|
+
* - Same CTO session ID recovers the same team context across restarts.
|
|
5
|
+
* - A different CTO session ID in the same worktree creates an independent team.
|
|
6
|
+
* - Engineers spawned within a CTO session resolve to that CTO's team.
|
|
7
|
+
* - CTO task permissions do not allow self-delegation.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
10
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
11
|
import { join } from 'node:path';
|
|
4
12
|
import { tmpdir } from 'node:os';
|
|
5
13
|
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
6
|
-
import { clearPluginServices,
|
|
7
|
-
import { AGENT_CTO } from '../src/plugin/
|
|
8
|
-
|
|
9
|
-
describe('CTO chat.message — persisted active team', () => {
|
|
14
|
+
import { clearPluginServices, getOrCreatePluginServices, getSessionTeam, getWrapperSessionMapping, registerParentSession, } from '../src/plugin/service-factory.js';
|
|
15
|
+
import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agents/index.js';
|
|
16
|
+
describe('CTO chat.message — session-per-team model', () => {
|
|
10
17
|
let tempRoot;
|
|
11
18
|
beforeEach(async () => {
|
|
12
19
|
tempRoot = await mkdtemp(join(tmpdir(), 'cto-team-'));
|
|
@@ -18,35 +25,175 @@ describe('CTO chat.message — persisted active team', () => {
|
|
|
18
25
|
await rm(tempRoot, { recursive: true, force: true });
|
|
19
26
|
}
|
|
20
27
|
});
|
|
21
|
-
it('
|
|
28
|
+
it('CTO session ID is used directly as the team ID', async () => {
|
|
22
29
|
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
23
30
|
const chatMessage = plugin['chat.message'];
|
|
24
|
-
await chatMessage({ agent: AGENT_CTO, sessionID: 'session-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
32
|
+
expect(getSessionTeam('cto-session-1')).toBe('cto-session-1');
|
|
33
|
+
});
|
|
34
|
+
it('same CTO session ID recovers its team context after a process restart', async () => {
|
|
35
|
+
// Phase 1: CTO session 'cto-1' runs and a team record is created on disk.
|
|
36
|
+
const plugin1 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
37
|
+
const chatMessage1 = plugin1['chat.message'];
|
|
38
|
+
await chatMessage1({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
39
|
+
const services1 = getOrCreatePluginServices(tempRoot);
|
|
40
|
+
await services1.orchestrator.getOrCreateTeam(tempRoot, 'cto-1'); // persist team to disk
|
|
41
|
+
expect(getSessionTeam('cto-1')).toBe('cto-1');
|
|
42
|
+
// Phase 2: Simulate a process restart (all in-memory state is lost).
|
|
43
|
+
clearPluginServices();
|
|
44
|
+
// Phase 3: The same CTO session ID resumes — it should re-register itself.
|
|
45
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
46
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
47
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
48
|
+
expect(getSessionTeam('cto-1')).toBe('cto-1');
|
|
49
|
+
// The persisted team record from Phase 1 should still be accessible.
|
|
50
|
+
const services2 = getOrCreatePluginServices(tempRoot);
|
|
51
|
+
const team = await services2.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
52
|
+
expect(team.id).toBe('cto-1');
|
|
53
|
+
});
|
|
54
|
+
it('a different CTO session ID creates an independent team, not adopting prior state', async () => {
|
|
55
|
+
// Phase 1: CTO session 'cto-1' runs.
|
|
56
|
+
const plugin1 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
57
|
+
const chatMessage1 = plugin1['chat.message'];
|
|
58
|
+
await chatMessage1({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
59
|
+
const services1 = getOrCreatePluginServices(tempRoot);
|
|
60
|
+
await services1.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
61
|
+
// Phase 2: Process restart.
|
|
62
|
+
clearPluginServices();
|
|
63
|
+
// Phase 3: A brand-new CTO session 'cto-2' starts.
|
|
64
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
65
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
66
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
|
|
67
|
+
// Must use its OWN session ID as team — must NOT adopt 'cto-1'.
|
|
68
|
+
expect(getSessionTeam('cto-2')).toBe('cto-2');
|
|
69
|
+
expect(getSessionTeam('cto-1')).toBeUndefined(); // cleared by restart
|
|
70
|
+
// 'cto-1' team data remains on disk, untouched.
|
|
71
|
+
const services2 = getOrCreatePluginServices(tempRoot);
|
|
72
|
+
const team1 = await services2.teamStore.getTeam(tempRoot, 'cto-1');
|
|
73
|
+
expect(team1).not.toBeNull(); // still present
|
|
74
|
+
expect(team1.id).toBe('cto-1');
|
|
75
|
+
});
|
|
76
|
+
it('multiple chat.message calls from the same CTO session do not change the active team', async () => {
|
|
33
77
|
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
34
78
|
const chatMessage = plugin['chat.message'];
|
|
35
|
-
|
|
36
|
-
await chatMessage({ agent: AGENT_CTO, sessionID: '
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
79
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
80
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
81
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
82
|
+
expect(getSessionTeam('cto-session-1')).toBe('cto-session-1');
|
|
83
|
+
});
|
|
84
|
+
it("engineers spawned during a CTO session resolve to that CTO session's team", async () => {
|
|
85
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
86
|
+
const chatMessage = plugin['chat.message'];
|
|
87
|
+
// CTO session fires first, establishing the active team.
|
|
88
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-A' });
|
|
89
|
+
// Simulate session.created event: OpenCode fires this before chat.message for the engineer.
|
|
90
|
+
registerParentSession('wrapper-tom-1', 'cto-A');
|
|
91
|
+
// Engineer wrapper session fires (spawned by the CTO).
|
|
92
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-1' });
|
|
93
|
+
// The wrapper session must be mapped to the CTO's team, not a new orphan team.
|
|
94
|
+
const mapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-1');
|
|
95
|
+
expect(mapping).toBeDefined();
|
|
96
|
+
expect(mapping.teamId).toBe('cto-A');
|
|
97
|
+
expect(mapping.workerName).toBe('Tom');
|
|
98
|
+
});
|
|
99
|
+
it('two concurrent CTO sessions each bind their own engineers independently', async () => {
|
|
100
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
101
|
+
const chatMessage = plugin['chat.message'];
|
|
102
|
+
// Two CTO sessions start concurrently in the same worktree.
|
|
103
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-alpha' });
|
|
104
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-beta' });
|
|
105
|
+
// Simulate session.created events: each CTO spawns one engineer sub-session.
|
|
106
|
+
// The event hook normally calls registerParentSession; call it directly here.
|
|
107
|
+
registerParentSession('wrapper-tom-alpha', 'cto-alpha');
|
|
108
|
+
registerParentSession('wrapper-sara-beta', 'cto-beta');
|
|
109
|
+
// Engineer wrapper sessions check in.
|
|
110
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-alpha' });
|
|
111
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Sara, sessionID: 'wrapper-sara-beta' });
|
|
112
|
+
// Tom must bind to cto-alpha's team, not cto-beta's.
|
|
113
|
+
const tomMapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-alpha');
|
|
114
|
+
expect(tomMapping).toBeDefined();
|
|
115
|
+
expect(tomMapping.teamId).toBe('cto-alpha');
|
|
116
|
+
// Sara must bind to cto-beta's team, not cto-alpha's.
|
|
117
|
+
const saraMapping = getWrapperSessionMapping(tempRoot, 'wrapper-sara-beta');
|
|
118
|
+
expect(saraMapping).toBeDefined();
|
|
119
|
+
expect(saraMapping.teamId).toBe('cto-beta');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('CTO task permissions — self-delegation', () => {
|
|
123
|
+
let tempRoot;
|
|
124
|
+
beforeEach(async () => {
|
|
125
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'cto-perms-'));
|
|
126
|
+
clearPluginServices();
|
|
127
|
+
});
|
|
128
|
+
afterEach(async () => {
|
|
129
|
+
clearPluginServices();
|
|
130
|
+
if (tempRoot)
|
|
131
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
it('CTO task permissions deny delegation to cto', async () => {
|
|
44
134
|
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
135
|
+
const config = {};
|
|
136
|
+
await plugin.config?.(config);
|
|
137
|
+
const agents = (config.agent ?? {});
|
|
138
|
+
const cto = agents[AGENT_CTO];
|
|
139
|
+
const taskPerms = cto.permission.task;
|
|
140
|
+
// Default deny must apply, and cto must not be explicitly allowed.
|
|
141
|
+
expect(taskPerms['*']).toBe('deny');
|
|
142
|
+
expect(taskPerms['cto']).toBeUndefined();
|
|
143
|
+
expect(taskPerms['CTO']).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('CTO tool isolation — concurrent sessions', () => {
|
|
147
|
+
let tempRoot;
|
|
148
|
+
beforeEach(async () => {
|
|
149
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'cto-isolation-'));
|
|
150
|
+
clearPluginServices();
|
|
151
|
+
});
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
clearPluginServices();
|
|
154
|
+
if (tempRoot)
|
|
155
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
it('team_status called from CTO-A uses CTO-A team even after CTO-B has chatted', async () => {
|
|
158
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
159
|
+
const chatMessage = plugin['chat.message'];
|
|
160
|
+
// CTO-A registers first, then CTO-B registers (simulating two concurrent sessions).
|
|
161
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-A' });
|
|
162
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-B' });
|
|
163
|
+
// Execute team_status as CTO-A (sessionID = 'cto-A').
|
|
164
|
+
const teamStatusTool = plugin.tool['team_status'];
|
|
165
|
+
const ctx = {
|
|
166
|
+
metadata: vi.fn(),
|
|
167
|
+
worktree: tempRoot,
|
|
168
|
+
sessionID: 'cto-A',
|
|
169
|
+
agent: AGENT_CTO,
|
|
170
|
+
abort: new AbortController().signal,
|
|
171
|
+
};
|
|
172
|
+
const result = JSON.parse(await teamStatusTool.execute({}, ctx));
|
|
173
|
+
// Must load cto-A's team, NOT cto-B's (last-write-wins global would give 'cto-B').
|
|
174
|
+
expect(result.id).toBe('cto-A');
|
|
175
|
+
});
|
|
176
|
+
it('resolves engineer team via live SDK lookup when session.created was not received', async () => {
|
|
177
|
+
// Simulate a client whose session.get returns parentID for the engineer session.
|
|
178
|
+
const mockClient = {
|
|
179
|
+
session: {
|
|
180
|
+
get: vi.fn().mockImplementation(async ({ path }) => {
|
|
181
|
+
if (path.id === 'wrapper-tom-live') {
|
|
182
|
+
return { data: { id: 'wrapper-tom-live', parentID: 'cto-live' } };
|
|
183
|
+
}
|
|
184
|
+
return { data: undefined };
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot, client: mockClient });
|
|
45
189
|
const chatMessage = plugin['chat.message'];
|
|
46
|
-
|
|
47
|
-
await chatMessage({ agent: AGENT_CTO, sessionID: '
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
190
|
+
// CTO registers its team via chat.message.
|
|
191
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-live' });
|
|
192
|
+
// No registerParentSession call — simulates session.created arriving late or being missed.
|
|
193
|
+
// Engineer wrapper fires; resolveTeamId must fall through to live SDK lookup.
|
|
194
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-live' });
|
|
195
|
+
const mapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-live');
|
|
196
|
+
expect(mapping?.teamId).toBe('cto-live');
|
|
197
|
+
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: 'wrapper-tom-live' } });
|
|
51
198
|
});
|
|
52
199
|
});
|
|
@@ -51,4 +51,19 @@ describe('managerPromptRegistry', () => {
|
|
|
51
51
|
expect(managerPromptRegistry.ctoSystemPrompt).toContain('team-planner');
|
|
52
52
|
expect(managerPromptRegistry.ctoSystemPrompt).toContain('automatically selects');
|
|
53
53
|
});
|
|
54
|
+
it('ctoSystemPrompt mentions browser-qa for delegation', () => {
|
|
55
|
+
expect(managerPromptRegistry.ctoSystemPrompt).toContain('browser-qa');
|
|
56
|
+
});
|
|
57
|
+
it('browserQaAgentPrompt mentions Playwright and PLAYWRIGHT_UNAVAILABLE sentinel', () => {
|
|
58
|
+
expect(managerPromptRegistry.browserQaAgentPrompt).toContain('Playwright');
|
|
59
|
+
expect(managerPromptRegistry.browserQaAgentPrompt).toContain('PLAYWRIGHT_UNAVAILABLE');
|
|
60
|
+
expect(managerPromptRegistry.browserQaAgentPrompt).toContain('browser QA');
|
|
61
|
+
});
|
|
62
|
+
it('browserQaSessionPrompt mentions Playwright and restricts write tools', () => {
|
|
63
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).toContain('Playwright');
|
|
64
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).toContain('Allowed tools');
|
|
65
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).toContain('browser QA');
|
|
66
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).not.toContain('implement');
|
|
67
|
+
expect(managerPromptRegistry.browserQaSessionPrompt).not.toContain('write code');
|
|
68
|
+
});
|
|
54
69
|
});
|
|
@@ -8,10 +8,18 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
11
|
-
import { clearPluginServices,
|
|
11
|
+
import { clearPluginServices, getOrCreatePluginServices, getSessionTeam, registerParentSession, } from '../src/plugin/service-factory.js';
|
|
12
12
|
import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agent-hierarchy.js';
|
|
13
13
|
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
14
14
|
import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
|
|
15
|
+
const BROWSER_QA_TEST_CAPS = {
|
|
16
|
+
sessionPrompt: 'Browser QA prompt',
|
|
17
|
+
restrictWriteTools: true,
|
|
18
|
+
skipModeInstructions: true,
|
|
19
|
+
plannerEligible: false,
|
|
20
|
+
isRuntimeUnavailableResponse: (text) => text.trimStart().startsWith('PLAYWRIGHT_UNAVAILABLE:'),
|
|
21
|
+
runtimeUnavailableTitle: '❌ Playwright unavailable',
|
|
22
|
+
};
|
|
15
23
|
function makeContext(worktree, agentId, sessionID) {
|
|
16
24
|
const metadata = vi.fn();
|
|
17
25
|
const ctx = {
|
|
@@ -100,7 +108,7 @@ describe('reportClaudeEvent — via plugin onEvent chain', () => {
|
|
|
100
108
|
expect(call.metadata.toolId).toBe('call-abc');
|
|
101
109
|
expect(call.metadata.toolArgs).toEqual({ file_path: '/foo.ts' });
|
|
102
110
|
expect(call.metadata.sessionId).toBe('ses-1');
|
|
103
|
-
expect(call.metadata.
|
|
111
|
+
expect(call.metadata.workerName).toBe('Tom');
|
|
104
112
|
});
|
|
105
113
|
it('double-decodes a JSON-string input (tool input serialised twice)', async () => {
|
|
106
114
|
// The SDK adapter may serialize `input` as a JSON string inside the outer JSON on
|
|
@@ -189,23 +197,25 @@ describe('second invocation continuity', () => {
|
|
|
189
197
|
if (tempRoot)
|
|
190
198
|
await rm(tempRoot, { recursive: true, force: true });
|
|
191
199
|
});
|
|
192
|
-
it('wrapper memory is injected after clearPluginServices and
|
|
200
|
+
it('wrapper memory is injected after clearPluginServices and same CTO session resumes', async () => {
|
|
193
201
|
// ── Phase 1: first task via orchestrator (no real SDK needed) ──────────
|
|
202
|
+
// Team ID = 'cto-1' (the CTO session ID that originally ran this work).
|
|
194
203
|
const store = new TeamStateStore();
|
|
195
|
-
|
|
196
|
-
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt');
|
|
204
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
197
205
|
await orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1');
|
|
198
206
|
await orchestrator.recordWrapperExchange(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1', 'explore', 'Investigate the auth flow', 'Found two race conditions in the token refresh path.');
|
|
199
207
|
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
200
208
|
clearPluginServices();
|
|
201
|
-
// ── Phase 3:
|
|
209
|
+
// ── Phase 3: same CTO session resumes, engineers run a new wrapper ──────
|
|
202
210
|
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
203
211
|
const chatMessage2 = plugin2['chat.message'];
|
|
204
212
|
const systemTransform2 = plugin2['experimental.chat.system.transform'];
|
|
205
|
-
//
|
|
206
|
-
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-
|
|
207
|
-
expect(
|
|
208
|
-
//
|
|
213
|
+
// Same CTO session ID resumes — re-registers itself as the team.
|
|
214
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
215
|
+
expect(getSessionTeam('cto-1')).toBe('cto-1');
|
|
216
|
+
// Simulate session.created: OpenCode fires this when cto-1 spawns the new wrapper.
|
|
217
|
+
registerParentSession('wrapper-tom-2', 'cto-1');
|
|
218
|
+
// Tom's new wrapper session is registered under the same team.
|
|
209
219
|
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
210
220
|
// Transform fires (after chat.message has registered the session mapping).
|
|
211
221
|
const output = { system: [] };
|
|
@@ -216,10 +226,9 @@ describe('second invocation continuity', () => {
|
|
|
216
226
|
expect(output.system[0]).toContain('Found two race conditions');
|
|
217
227
|
});
|
|
218
228
|
it('existing engineer Claude session is resumed on second invocation', async () => {
|
|
219
|
-
// ── Phase 1: pre-seed Tom with a claudeSessionId
|
|
229
|
+
// ── Phase 1: pre-seed Tom with a claudeSessionId under team 'cto-1' ────
|
|
220
230
|
const store = new TeamStateStore();
|
|
221
|
-
|
|
222
|
-
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt');
|
|
231
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
223
232
|
await orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
224
233
|
await store.updateTeam(tempRoot, 'cto-1', (team) => ({
|
|
225
234
|
...team,
|
|
@@ -227,10 +236,13 @@ describe('second invocation continuity', () => {
|
|
|
227
236
|
}));
|
|
228
237
|
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
229
238
|
clearPluginServices();
|
|
230
|
-
// ── Phase 3:
|
|
239
|
+
// ── Phase 3: same CTO session resumes, engineer runs second task ───────
|
|
231
240
|
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
232
241
|
const chatMessage2 = plugin2['chat.message'];
|
|
233
|
-
|
|
242
|
+
// Same CTO session ID — re-registers as the team so Tom can find his session.
|
|
243
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
244
|
+
// Simulate session.created: OpenCode fires this when cto-1 spawns the new wrapper.
|
|
245
|
+
registerParentSession('wrapper-tom-2', 'cto-1');
|
|
234
246
|
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
235
247
|
const services2 = getOrCreatePluginServices(tempRoot);
|
|
236
248
|
// Mock at the session level so dispatchEngineer runs its real logic
|
|
@@ -256,4 +268,37 @@ describe('second invocation continuity', () => {
|
|
|
256
268
|
});
|
|
257
269
|
expect(runTask.mock.calls[0]?.[0].systemPrompt).toBeUndefined(); // no system prompt when resuming
|
|
258
270
|
});
|
|
271
|
+
it('BrowserQA PLAYWRIGHT_UNAVAILABLE returns plainly and emits warning metadata', async () => {
|
|
272
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
273
|
+
const chatMessage = plugin['chat.message'];
|
|
274
|
+
// Register BrowserQA session
|
|
275
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.BrowserQA, sessionID: 'wrapper-browserqa-1' });
|
|
276
|
+
const services = getOrCreatePluginServices(tempRoot);
|
|
277
|
+
// Mock dispatchEngineer to return PLAYWRIGHT_UNAVAILABLE sentinel
|
|
278
|
+
const unavailableMessage = 'PLAYWRIGHT_UNAVAILABLE: Playwright is not available in this environment. Consider installing it via npm.';
|
|
279
|
+
vi.spyOn(services.orchestrator, 'dispatchEngineer').mockResolvedValueOnce(makeDispatchResult({
|
|
280
|
+
engineer: 'BrowserQA',
|
|
281
|
+
finalText: unavailableMessage,
|
|
282
|
+
}));
|
|
283
|
+
vi.spyOn(services.orchestrator, 'recordWrapperExchange').mockResolvedValue(undefined);
|
|
284
|
+
const { metadata, ctx } = makeContext(tempRoot, ENGINEER_AGENT_IDS.BrowserQA, 'wrapper-browserqa-1');
|
|
285
|
+
const result = await executeClaude(plugin, ctx, {
|
|
286
|
+
mode: 'verify',
|
|
287
|
+
message: 'Check if browser is available',
|
|
288
|
+
});
|
|
289
|
+
// Result should contain the PLAYWRIGHT_UNAVAILABLE text plainly (not wrapped in error)
|
|
290
|
+
expect(result).toContain('PLAYWRIGHT_UNAVAILABLE');
|
|
291
|
+
expect(result).toContain('Playwright is not available');
|
|
292
|
+
// Verify warning metadata was emitted
|
|
293
|
+
expect(metadata).toHaveBeenCalled();
|
|
294
|
+
const calls = metadata.mock.calls;
|
|
295
|
+
const warningCall = calls.find((call) => {
|
|
296
|
+
const arg = call[0];
|
|
297
|
+
const title = arg?.title ?? '';
|
|
298
|
+
return (title.includes('Playwright') ||
|
|
299
|
+
title.includes('unavailable') ||
|
|
300
|
+
title.toLowerCase().includes('playwright unavailable'));
|
|
301
|
+
});
|
|
302
|
+
expect(warningCall).toBeDefined();
|
|
303
|
+
});
|
|
259
304
|
});
|
|
@@ -4,6 +4,15 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
|
|
6
6
|
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
7
|
+
// Full BrowserQA capabilities used in all test orchestrator instances
|
|
8
|
+
const BROWSER_QA_TEST_CAPS = {
|
|
9
|
+
sessionPrompt: 'Browser QA prompt',
|
|
10
|
+
restrictWriteTools: true,
|
|
11
|
+
skipModeInstructions: true,
|
|
12
|
+
plannerEligible: false,
|
|
13
|
+
isRuntimeUnavailableResponse: (text) => text.trimStart().startsWith('PLAYWRIGHT_UNAVAILABLE:'),
|
|
14
|
+
runtimeUnavailableTitle: '❌ Playwright unavailable',
|
|
15
|
+
};
|
|
7
16
|
describe('TeamOrchestrator', () => {
|
|
8
17
|
let tempRoot;
|
|
9
18
|
afterEach(async () => {
|
|
@@ -35,7 +44,7 @@ describe('TeamOrchestrator', () => {
|
|
|
35
44
|
outputTokens: 300,
|
|
36
45
|
contextWindowSize: 200_000,
|
|
37
46
|
});
|
|
38
|
-
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
47
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
39
48
|
const first = await orchestrator.dispatchEngineer({
|
|
40
49
|
teamId: 'team-1',
|
|
41
50
|
cwd: tempRoot,
|
|
@@ -79,7 +88,7 @@ describe('TeamOrchestrator', () => {
|
|
|
79
88
|
it('rejects work when the same engineer is already busy', async () => {
|
|
80
89
|
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
81
90
|
const store = new TeamStateStore('.state');
|
|
82
|
-
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
91
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
83
92
|
const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
84
93
|
await store.saveTeam({
|
|
85
94
|
...team,
|
|
@@ -117,7 +126,7 @@ describe('TeamOrchestrator', () => {
|
|
|
117
126
|
events: [],
|
|
118
127
|
finalText: '## Synthesis\nCombined plan\n## Recommended Question\nShould we migrate now?\n## Recommended Answer\nNo, defer it.',
|
|
119
128
|
});
|
|
120
|
-
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
129
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
121
130
|
const result = await orchestrator.planWithTeam({
|
|
122
131
|
teamId: 'team-1',
|
|
123
132
|
cwd: tempRoot,
|
|
@@ -166,7 +175,7 @@ describe('TeamOrchestrator', () => {
|
|
|
166
175
|
};
|
|
167
176
|
}
|
|
168
177
|
});
|
|
169
|
-
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
178
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
170
179
|
const onLeadEvent = vi.fn();
|
|
171
180
|
const onChallengerEvent = vi.fn();
|
|
172
181
|
const onSynthesisEvent = vi.fn();
|
|
@@ -186,7 +195,7 @@ describe('TeamOrchestrator', () => {
|
|
|
186
195
|
});
|
|
187
196
|
it('persists wrapper session memory for an engineer', async () => {
|
|
188
197
|
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
189
|
-
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
198
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
190
199
|
await orchestrator.recordWrapperSession(tempRoot, 'team-1', 'Tom', 'wrapper-tom');
|
|
191
200
|
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.');
|
|
192
201
|
const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
@@ -240,7 +249,7 @@ describe('TeamOrchestrator', () => {
|
|
|
240
249
|
};
|
|
241
250
|
}
|
|
242
251
|
});
|
|
243
|
-
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
252
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
244
253
|
const result = await orchestrator.planWithTeam({
|
|
245
254
|
teamId: 'team-1',
|
|
246
255
|
cwd: tempRoot,
|
|
@@ -253,7 +262,7 @@ describe('TeamOrchestrator', () => {
|
|
|
253
262
|
});
|
|
254
263
|
it('throws error when fewer than 2 viable engineers exist for planning', async () => {
|
|
255
264
|
tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
|
|
256
|
-
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
265
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
257
266
|
// Mark all engineers as busy
|
|
258
267
|
const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
259
268
|
for (const engineer of team.engineers) {
|
|
@@ -292,7 +301,7 @@ describe('TeamOrchestrator', () => {
|
|
|
292
301
|
contextWindowSize: 200_000,
|
|
293
302
|
};
|
|
294
303
|
});
|
|
295
|
-
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt');
|
|
304
|
+
const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
296
305
|
const allEvents = [];
|
|
297
306
|
const result = await orchestrator.dispatchEngineer({
|
|
298
307
|
teamId: 'team-1',
|
|
@@ -314,4 +323,34 @@ describe('TeamOrchestrator', () => {
|
|
|
314
323
|
// Verify success result
|
|
315
324
|
expect(result.finalText).toBe('Success after retry');
|
|
316
325
|
});
|
|
326
|
+
it('rejects BrowserQA with implement mode', async () => {
|
|
327
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'browserqa-implement-'));
|
|
328
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
329
|
+
const error = await orchestrator
|
|
330
|
+
.dispatchEngineer({
|
|
331
|
+
teamId: 'team-1',
|
|
332
|
+
cwd: tempRoot,
|
|
333
|
+
engineer: 'BrowserQA',
|
|
334
|
+
mode: 'implement',
|
|
335
|
+
message: 'Write a feature',
|
|
336
|
+
})
|
|
337
|
+
.catch((e) => e);
|
|
338
|
+
expect(error).toBeInstanceOf(Error);
|
|
339
|
+
expect(error.message).toContain('BrowserQA is a browser QA specialist');
|
|
340
|
+
expect(error.message).toContain('does not support implement mode');
|
|
341
|
+
});
|
|
342
|
+
it('selectPlanEngineers excludes BrowserQA from planner selection', async () => {
|
|
343
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'planner-exclude-browserqa-'));
|
|
344
|
+
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Engineer prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
345
|
+
// Create a team with all engineers
|
|
346
|
+
await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
|
|
347
|
+
// Select plan engineers
|
|
348
|
+
const selection = await orchestrator.selectPlanEngineers(tempRoot, 'team-1');
|
|
349
|
+
// Both lead and challenger should be from general engineers only
|
|
350
|
+
const generalEngineers = ['Tom', 'John', 'Maya', 'Sara', 'Alex'];
|
|
351
|
+
expect(generalEngineers).toContain(selection.lead);
|
|
352
|
+
expect(generalEngineers).toContain(selection.challenger);
|
|
353
|
+
expect(selection.lead).not.toBe('BrowserQA');
|
|
354
|
+
expect(selection.challenger).not.toBe('BrowserQA');
|
|
355
|
+
});
|
|
317
356
|
});
|
|
@@ -51,22 +51,4 @@ describe('TeamStateStore', () => {
|
|
|
51
51
|
{ id: 'older' },
|
|
52
52
|
]);
|
|
53
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
54
|
});
|