@doingdev/opencode-claude-manager-plugin 0.1.62 → 0.1.64

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.
@@ -0,0 +1,837 @@
1
+ /**
2
+ * Tests for coordinated CTO undo propagation.
3
+ *
4
+ * When the user undoes a CTO session turn (session.updated with revert marker):
5
+ * - Only CTO sessions trigger propagation (not engineer wrapper sessions).
6
+ * - The same revert marker is processed at most once per session (dedup).
7
+ * - Engineer wrapper sessions in OpenCode are reverted.
8
+ * - Inner Claude Code sessions receive /undo for each affected exchange.
9
+ * - Stale wrapper history entries are pruned from disk.
10
+ * - One engineer failure does not prevent other engineers from being processed.
11
+ */
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { mkdtemp, rm } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
17
+ import { clearPluginServices, getOrCreatePluginServices, isRevertAlreadyProcessed, } from '../src/plugin/service-factory.js';
18
+ import { AGENT_CTO } from '../src/plugin/agents/index.js';
19
+ import { TeamStateStore } from '../src/state/team-state-store.js';
20
+ function makeSessionUpdatedEvent(sessionId, revertMessageId) {
21
+ return {
22
+ type: 'session.updated',
23
+ properties: {
24
+ info: {
25
+ id: sessionId,
26
+ title: 'test session',
27
+ version: '1',
28
+ projectID: 'project-1',
29
+ directory: '/tmp',
30
+ time: { created: 1_000_000, updated: 2_000_000 },
31
+ ...(revertMessageId !== undefined && { revert: { messageID: revertMessageId } }),
32
+ },
33
+ },
34
+ };
35
+ }
36
+ /**
37
+ * Build a lightweight mock OpenCode client.
38
+ * `ctoMessageTimestamp` is the Unix-ms timestamp for the reverted CTO message.
39
+ * `wrapperMessages` is what client.session.messages returns for wrapper sessions.
40
+ */
41
+ function buildMockClient(options) {
42
+ const { ctoMessageTimestamp = 1_500_000, wrapperMessages = [], revertSpy = vi.fn() } = options;
43
+ return {
44
+ session: {
45
+ get: vi.fn().mockResolvedValue({ data: undefined }),
46
+ message: vi.fn().mockResolvedValue({
47
+ data: {
48
+ info: {
49
+ id: 'msg-rev-1',
50
+ role: 'user',
51
+ sessionID: 'cto-1',
52
+ time: { created: ctoMessageTimestamp },
53
+ },
54
+ parts: [],
55
+ },
56
+ }),
57
+ messages: vi.fn().mockResolvedValue({ data: wrapperMessages }),
58
+ revert: revertSpy,
59
+ },
60
+ };
61
+ }
62
+ function makeWrapperEntry(type, timestamp) {
63
+ return { timestamp, type, mode: 'implement', text: `${type} at ${timestamp}` };
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Detection and filtering
67
+ // ---------------------------------------------------------------------------
68
+ describe('CTO undo propagation — detection and filtering', () => {
69
+ let tempRoot;
70
+ beforeEach(async () => {
71
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-detect-'));
72
+ clearPluginServices();
73
+ });
74
+ afterEach(async () => {
75
+ clearPluginServices();
76
+ if (tempRoot)
77
+ await rm(tempRoot, { recursive: true, force: true });
78
+ });
79
+ it('does nothing when session.updated has no revert marker', async () => {
80
+ const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
81
+ const chatMessage = plugin['chat.message'];
82
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
83
+ const eventFn = plugin.event;
84
+ // No error — event without revert is silently ignored
85
+ await expect(eventFn({ event: makeSessionUpdatedEvent('cto-1') })).resolves.toBeUndefined();
86
+ expect(isRevertAlreadyProcessed('cto-1', 'any-msg')).toBe(false);
87
+ });
88
+ it('skips session.updated for sessions not registered as CTO teams', async () => {
89
+ const mockClient = buildMockClient({});
90
+ const plugin = await ClaudeManagerPlugin({
91
+ worktree: tempRoot,
92
+ client: mockClient,
93
+ });
94
+ // 'unknown-session' was never registered via chat.message for AGENT_CTO
95
+ const eventFn = plugin.event;
96
+ await eventFn({ event: makeSessionUpdatedEvent('unknown-session', 'msg-rev-1') });
97
+ // No propagation — message lookup should not have been attempted
98
+ expect(mockClient.session.message).not.toHaveBeenCalled();
99
+ expect(isRevertAlreadyProcessed('unknown-session', 'msg-rev-1')).toBe(false);
100
+ });
101
+ it('marks the revert as processed after handling a CTO session.updated event', async () => {
102
+ const mockClient = buildMockClient({ ctoMessageTimestamp: 1_500_000 });
103
+ const plugin = await ClaudeManagerPlugin({
104
+ worktree: tempRoot,
105
+ client: mockClient,
106
+ });
107
+ const chatMessage = plugin['chat.message'];
108
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
109
+ // Create the team so orchestrator.getOrCreateTeam doesn't fail
110
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
111
+ const eventFn = plugin.event;
112
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
113
+ expect(isRevertAlreadyProcessed('cto-1', 'msg-rev-1')).toBe(true);
114
+ });
115
+ it('propagates undo for a CTO team found only on disk (in-memory registry empty after restart)', async () => {
116
+ const ctoMessageTimestamp = 1_500_000;
117
+ const mockClient = buildMockClient({ ctoMessageTimestamp });
118
+ const plugin = await ClaudeManagerPlugin({
119
+ worktree: tempRoot,
120
+ client: mockClient,
121
+ });
122
+ // Seed the team on disk WITHOUT registering it in the in-memory session-team registry
123
+ // (simulates a process restart where the registry was cleared but disk state persists)
124
+ const services = getOrCreatePluginServices(tempRoot);
125
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-persisted');
126
+ // Do NOT call chatMessage for AGENT_CTO — leaves sessionTeamRegistry empty
127
+ const eventFn = plugin.event;
128
+ await eventFn({ event: makeSessionUpdatedEvent('cto-persisted', 'msg-rev-1') });
129
+ // The persisted fallback should have found the team and attempted the CTO message lookup
130
+ expect(mockClient.session.message).toHaveBeenCalledOnce();
131
+ expect(isRevertAlreadyProcessed('cto-persisted', 'msg-rev-1')).toBe(true);
132
+ });
133
+ it('does not propagate when client is absent (no timestamp lookup possible)', async () => {
134
+ // Plugin created without a client — should not throw and should not mark as processed
135
+ const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
136
+ const chatMessage = plugin['chat.message'];
137
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-no-client' });
138
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-no-client');
139
+ const eventFn = plugin.event;
140
+ await expect(eventFn({ event: makeSessionUpdatedEvent('cto-no-client', 'msg-rev-1') })).resolves.toBeUndefined();
141
+ // Cutoff resolution throws when client is absent; outer catch clears the marker
142
+ // so a subsequent event (e.g. after client becomes available) can retry.
143
+ expect(isRevertAlreadyProcessed('cto-no-client', 'msg-rev-1')).toBe(false);
144
+ });
145
+ });
146
+ // ---------------------------------------------------------------------------
147
+ // Deduplication lifecycle
148
+ // ---------------------------------------------------------------------------
149
+ describe('CTO undo propagation — dedup lifecycle (redo then undo again)', () => {
150
+ let tempRoot;
151
+ beforeEach(async () => {
152
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-dedup-lifecycle-'));
153
+ clearPluginServices();
154
+ });
155
+ afterEach(async () => {
156
+ clearPluginServices();
157
+ if (tempRoot)
158
+ await rm(tempRoot, { recursive: true, force: true });
159
+ });
160
+ it('re-processes the same revert marker after a session.updated with no revert clears it', async () => {
161
+ const mockClient = buildMockClient({ ctoMessageTimestamp: 1_500_000 });
162
+ const plugin = await ClaudeManagerPlugin({
163
+ worktree: tempRoot,
164
+ client: mockClient,
165
+ });
166
+ const chatMessage = plugin['chat.message'];
167
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
168
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
169
+ const eventFn = plugin.event;
170
+ // First undo: msg-rev-1 is processed once
171
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
172
+ expect(mockClient.session.message).toHaveBeenCalledTimes(1);
173
+ // Redo: revert marker disappears — should clear the dedup entry
174
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1') });
175
+ expect(isRevertAlreadyProcessed('cto-1', 'msg-rev-1')).toBe(false);
176
+ // Undo again with the same message ID — must be processed again
177
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
178
+ expect(mockClient.session.message).toHaveBeenCalledTimes(2);
179
+ });
180
+ });
181
+ // ---------------------------------------------------------------------------
182
+ // Deduplication
183
+ // ---------------------------------------------------------------------------
184
+ describe('CTO undo propagation — deduplication', () => {
185
+ let tempRoot;
186
+ beforeEach(async () => {
187
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-dedup-'));
188
+ clearPluginServices();
189
+ });
190
+ afterEach(async () => {
191
+ clearPluginServices();
192
+ if (tempRoot)
193
+ await rm(tempRoot, { recursive: true, force: true });
194
+ });
195
+ it('processes the revert only once when session.updated fires multiple times', async () => {
196
+ const mockClient = buildMockClient({ ctoMessageTimestamp: 1_500_000 });
197
+ const plugin = await ClaudeManagerPlugin({
198
+ worktree: tempRoot,
199
+ client: mockClient,
200
+ });
201
+ const chatMessage = plugin['chat.message'];
202
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
203
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
204
+ const eventFn = plugin.event;
205
+ const event = makeSessionUpdatedEvent('cto-1', 'msg-rev-1');
206
+ await eventFn({ event });
207
+ await eventFn({ event }); // Second call — should be deduped
208
+ await eventFn({ event }); // Third call — should also be deduped
209
+ // CTO message lookup must only happen once
210
+ expect(mockClient.session.message).toHaveBeenCalledOnce();
211
+ });
212
+ it('processes distinct revert markers independently', async () => {
213
+ const mockClient = buildMockClient({ ctoMessageTimestamp: 1_500_000 });
214
+ const plugin = await ClaudeManagerPlugin({
215
+ worktree: tempRoot,
216
+ client: mockClient,
217
+ });
218
+ const chatMessage = plugin['chat.message'];
219
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
220
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
221
+ const eventFn = plugin.event;
222
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-A') });
223
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-B') });
224
+ // Two distinct revert markers → two message lookups
225
+ expect(mockClient.session.message).toHaveBeenCalledTimes(2);
226
+ });
227
+ });
228
+ // ---------------------------------------------------------------------------
229
+ // Wrapper history pruning
230
+ // ---------------------------------------------------------------------------
231
+ describe('CTO undo propagation — wrapper history pruning', () => {
232
+ let tempRoot;
233
+ beforeEach(async () => {
234
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-prune-'));
235
+ clearPluginServices();
236
+ });
237
+ afterEach(async () => {
238
+ clearPluginServices();
239
+ if (tempRoot)
240
+ await rm(tempRoot, { recursive: true, force: true });
241
+ });
242
+ it('prunes wrapper history entries that were added after the cutoff timestamp', async () => {
243
+ // CTO message timestamp: 1_500_000 ms → cutoffIso = new Date(1_500_000).toISOString()
244
+ const ctoMessageTimestamp = 1_500_000;
245
+ const cutoffIso = new Date(ctoMessageTimestamp).toISOString();
246
+ const beforeCutoff = new Date(ctoMessageTimestamp - 1000).toISOString();
247
+ const afterCutoff1 = new Date(ctoMessageTimestamp + 1000).toISOString();
248
+ const afterCutoff2 = new Date(ctoMessageTimestamp + 2000).toISOString();
249
+ const mockClient = buildMockClient({ ctoMessageTimestamp });
250
+ const plugin = await ClaudeManagerPlugin({
251
+ worktree: tempRoot,
252
+ client: mockClient,
253
+ });
254
+ const chatMessage = plugin['chat.message'];
255
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
256
+ const services = getOrCreatePluginServices(tempRoot);
257
+ // Seed the team with wrapper history spanning the cutoff
258
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
259
+ await services.orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom');
260
+ // Directly persist wrapper history via recordWrapperExchange (timestamps use Date.now internally)
261
+ // Instead write directly to the store to control timestamps precisely.
262
+ const store = new TeamStateStore();
263
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
264
+ ...team,
265
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
266
+ ? {
267
+ ...eng,
268
+ wrapperSessionId: 'wrapper-tom',
269
+ wrapperHistory: [
270
+ makeWrapperEntry('assignment', beforeCutoff),
271
+ makeWrapperEntry('result', beforeCutoff),
272
+ makeWrapperEntry('assignment', afterCutoff1),
273
+ makeWrapperEntry('result', afterCutoff1),
274
+ makeWrapperEntry('assignment', afterCutoff2),
275
+ makeWrapperEntry('result', afterCutoff2),
276
+ ],
277
+ }
278
+ : eng),
279
+ }));
280
+ // Fire the undo event
281
+ const eventFn = plugin.event;
282
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
283
+ // Verify that only entries at or before the cutoff remain
284
+ const team = await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
285
+ const tom = team.engineers.find((e) => e.name === 'Tom');
286
+ expect(tom?.wrapperHistory).toHaveLength(2);
287
+ expect(tom?.wrapperHistory.every((h) => h.timestamp <= cutoffIso)).toBe(true);
288
+ });
289
+ it('leaves wrapper history untouched when all entries are before the cutoff', async () => {
290
+ const ctoMessageTimestamp = 5_000_000;
291
+ const mockClient = buildMockClient({ ctoMessageTimestamp });
292
+ const plugin = await ClaudeManagerPlugin({
293
+ worktree: tempRoot,
294
+ client: mockClient,
295
+ });
296
+ const chatMessage = plugin['chat.message'];
297
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
298
+ const services = getOrCreatePluginServices(tempRoot);
299
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
300
+ const early = new Date(1_000_000).toISOString();
301
+ const store = new TeamStateStore();
302
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
303
+ ...team,
304
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
305
+ ? {
306
+ ...eng,
307
+ wrapperHistory: [
308
+ makeWrapperEntry('assignment', early),
309
+ makeWrapperEntry('result', early),
310
+ ],
311
+ }
312
+ : eng),
313
+ }));
314
+ const eventFn = plugin.event;
315
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-2') });
316
+ const team = await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
317
+ const tom = team.engineers.find((e) => e.name === 'Tom');
318
+ // Nothing after cutoff → all entries preserved
319
+ expect(tom?.wrapperHistory).toHaveLength(2);
320
+ });
321
+ });
322
+ // ---------------------------------------------------------------------------
323
+ // Inner Claude session /undo calls
324
+ // ---------------------------------------------------------------------------
325
+ describe('CTO undo propagation — inner Claude session /undo', () => {
326
+ let tempRoot;
327
+ beforeEach(async () => {
328
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-claude-'));
329
+ clearPluginServices();
330
+ });
331
+ afterEach(async () => {
332
+ clearPluginServices();
333
+ if (tempRoot)
334
+ await rm(tempRoot, { recursive: true, force: true });
335
+ });
336
+ it('sends /undo once per assignment exchange that occurred after the cutoff', async () => {
337
+ const ctoMessageTimestamp = 1_500_000;
338
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
339
+ const afterCutoff2 = new Date(ctoMessageTimestamp + 2000).toISOString();
340
+ const mockClient = buildMockClient({ ctoMessageTimestamp });
341
+ const plugin = await ClaudeManagerPlugin({
342
+ worktree: tempRoot,
343
+ client: mockClient,
344
+ });
345
+ const chatMessage = plugin['chat.message'];
346
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
347
+ const services = getOrCreatePluginServices(tempRoot);
348
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
349
+ // Set up Tom with 2 exchanges after cutoff and a claudeSessionId
350
+ const store = new TeamStateStore();
351
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
352
+ ...team,
353
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
354
+ ? {
355
+ ...eng,
356
+ claudeSessionId: 'ses-claude-tom',
357
+ wrapperHistory: [
358
+ makeWrapperEntry('assignment', afterCutoff),
359
+ makeWrapperEntry('result', afterCutoff),
360
+ makeWrapperEntry('assignment', afterCutoff2),
361
+ makeWrapperEntry('result', afterCutoff2),
362
+ ],
363
+ }
364
+ : eng),
365
+ }));
366
+ // Spy on sessions.runTask to capture /undo calls
367
+ const mockRunTask = vi
368
+ .spyOn(services.sessions, 'runTask')
369
+ .mockResolvedValue({ sessionId: 'ses-claude-tom', events: [], finalText: '' });
370
+ const eventFn = plugin.event;
371
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
372
+ // Expect 2 /undo calls — one per assignment exchange
373
+ const undoCalls = mockRunTask.mock.calls.filter(([input]) => input.prompt === '/undo');
374
+ expect(undoCalls).toHaveLength(2);
375
+ for (const [input] of undoCalls) {
376
+ expect(input.resumeSessionId).toBe('ses-claude-tom');
377
+ expect(input.persistSession).toBe(true);
378
+ expect(input.maxTurns).toBe(1);
379
+ }
380
+ });
381
+ it('stops sending /undo for an engineer when runTask throws', async () => {
382
+ const ctoMessageTimestamp = 1_500_000;
383
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
384
+ const afterCutoff2 = new Date(ctoMessageTimestamp + 2000).toISOString();
385
+ const mockClient = buildMockClient({ ctoMessageTimestamp });
386
+ const plugin = await ClaudeManagerPlugin({
387
+ worktree: tempRoot,
388
+ client: mockClient,
389
+ });
390
+ const chatMessage = plugin['chat.message'];
391
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
392
+ const services = getOrCreatePluginServices(tempRoot);
393
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
394
+ const store = new TeamStateStore();
395
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
396
+ ...team,
397
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
398
+ ? {
399
+ ...eng,
400
+ claudeSessionId: 'ses-claude-tom',
401
+ wrapperHistory: [
402
+ makeWrapperEntry('assignment', afterCutoff),
403
+ makeWrapperEntry('result', afterCutoff),
404
+ makeWrapperEntry('assignment', afterCutoff2),
405
+ makeWrapperEntry('result', afterCutoff2),
406
+ ],
407
+ }
408
+ : eng),
409
+ }));
410
+ // First /undo call throws — should stop further attempts
411
+ const mockRunTask = vi
412
+ .spyOn(services.sessions, 'runTask')
413
+ .mockRejectedValueOnce(new Error('undo failed'))
414
+ .mockResolvedValue({ sessionId: 'ses-claude-tom', events: [], finalText: '' });
415
+ const eventFn = plugin.event;
416
+ // Event hook must not throw
417
+ await expect(eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') })).resolves.toBeUndefined();
418
+ // Only one /undo attempt — stopped after the first failure
419
+ const undoCalls = mockRunTask.mock.calls.filter(([input]) => input.prompt === '/undo');
420
+ expect(undoCalls).toHaveLength(1);
421
+ });
422
+ });
423
+ // ---------------------------------------------------------------------------
424
+ // Wrapper session revert
425
+ // ---------------------------------------------------------------------------
426
+ describe('CTO undo propagation — wrapper session revert', () => {
427
+ let tempRoot;
428
+ beforeEach(async () => {
429
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-wrapper-'));
430
+ clearPluginServices();
431
+ });
432
+ afterEach(async () => {
433
+ clearPluginServices();
434
+ if (tempRoot)
435
+ await rm(tempRoot, { recursive: true, force: true });
436
+ });
437
+ it('reverts the wrapper session to the first user message after the cutoff', async () => {
438
+ const ctoMessageTimestamp = 1_500_000;
439
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
440
+ const beforeCutoff = new Date(ctoMessageTimestamp - 1000).toISOString();
441
+ const revertSpy = vi.fn().mockResolvedValue({ data: {} });
442
+ const mockClient = buildMockClient({
443
+ ctoMessageTimestamp,
444
+ wrapperMessages: [
445
+ {
446
+ info: {
447
+ id: 'msg-wrapper-old',
448
+ role: 'user',
449
+ time: { created: ctoMessageTimestamp - 5000 },
450
+ },
451
+ },
452
+ {
453
+ info: {
454
+ id: 'msg-wrapper-new',
455
+ role: 'user',
456
+ time: { created: ctoMessageTimestamp + 500 },
457
+ },
458
+ },
459
+ ],
460
+ revertSpy,
461
+ });
462
+ const plugin = await ClaudeManagerPlugin({
463
+ worktree: tempRoot,
464
+ client: mockClient,
465
+ });
466
+ const chatMessage = plugin['chat.message'];
467
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
468
+ const services = getOrCreatePluginServices(tempRoot);
469
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
470
+ const store = new TeamStateStore();
471
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
472
+ ...team,
473
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
474
+ ? {
475
+ ...eng,
476
+ wrapperSessionId: 'wrapper-tom',
477
+ wrapperHistory: [
478
+ makeWrapperEntry('assignment', beforeCutoff),
479
+ makeWrapperEntry('result', beforeCutoff),
480
+ makeWrapperEntry('assignment', afterCutoff),
481
+ makeWrapperEntry('result', afterCutoff),
482
+ ],
483
+ }
484
+ : eng),
485
+ }));
486
+ vi.spyOn(services.sessions, 'runTask').mockResolvedValue({
487
+ sessionId: undefined,
488
+ events: [],
489
+ finalText: '',
490
+ });
491
+ const eventFn = plugin.event;
492
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
493
+ // Should revert to the first message after the CTO message timestamp
494
+ expect(revertSpy).toHaveBeenCalledWith(expect.objectContaining({
495
+ path: { id: 'wrapper-tom' },
496
+ body: { messageID: 'msg-wrapper-new' },
497
+ }));
498
+ });
499
+ it('skips wrapper session revert when no wrapper messages are after the cutoff', async () => {
500
+ const ctoMessageTimestamp = 1_500_000;
501
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
502
+ const revertSpy = vi.fn().mockResolvedValue({ data: {} });
503
+ const mockClient = buildMockClient({
504
+ ctoMessageTimestamp,
505
+ // All wrapper messages are before the cutoff
506
+ wrapperMessages: [
507
+ {
508
+ info: {
509
+ id: 'msg-wrapper-old',
510
+ role: 'user',
511
+ time: { created: ctoMessageTimestamp - 5000 },
512
+ },
513
+ },
514
+ ],
515
+ revertSpy,
516
+ });
517
+ const plugin = await ClaudeManagerPlugin({
518
+ worktree: tempRoot,
519
+ client: mockClient,
520
+ });
521
+ const chatMessage = plugin['chat.message'];
522
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
523
+ const services = getOrCreatePluginServices(tempRoot);
524
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
525
+ const store = new TeamStateStore();
526
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
527
+ ...team,
528
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
529
+ ? {
530
+ ...eng,
531
+ wrapperSessionId: 'wrapper-tom',
532
+ wrapperHistory: [makeWrapperEntry('assignment', afterCutoff)],
533
+ }
534
+ : eng),
535
+ }));
536
+ vi.spyOn(services.sessions, 'runTask').mockResolvedValue({
537
+ sessionId: undefined,
538
+ events: [],
539
+ finalText: '',
540
+ });
541
+ const eventFn = plugin.event;
542
+ await eventFn({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') });
543
+ // No wrapper messages after cutoff → no revert call
544
+ expect(revertSpy).not.toHaveBeenCalled();
545
+ });
546
+ });
547
+ // ---------------------------------------------------------------------------
548
+ // Best-effort safety
549
+ // ---------------------------------------------------------------------------
550
+ describe('CTO undo propagation — best-effort safety', () => {
551
+ let tempRoot;
552
+ beforeEach(async () => {
553
+ tempRoot = await mkdtemp(join(tmpdir(), 'undo-safety-'));
554
+ clearPluginServices();
555
+ });
556
+ afterEach(async () => {
557
+ clearPluginServices();
558
+ if (tempRoot)
559
+ await rm(tempRoot, { recursive: true, force: true });
560
+ });
561
+ it('processes all engineers even when one wrapper session revert throws', async () => {
562
+ const ctoMessageTimestamp = 1_500_000;
563
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
564
+ // First messages() call throws (for Tom's wrapper session), second succeeds (for John).
565
+ const mockClient = {
566
+ session: {
567
+ get: vi.fn().mockResolvedValue({ data: undefined }),
568
+ message: vi.fn().mockResolvedValue({
569
+ data: {
570
+ info: { id: 'msg-rev-1', role: 'user', time: { created: ctoMessageTimestamp } },
571
+ parts: [],
572
+ },
573
+ }),
574
+ messages: vi
575
+ .fn()
576
+ .mockRejectedValueOnce(new Error('wrapper session fetch failed'))
577
+ .mockResolvedValue({ data: [] }),
578
+ revert: vi.fn().mockResolvedValue({ data: {} }),
579
+ },
580
+ };
581
+ const plugin = await ClaudeManagerPlugin({
582
+ worktree: tempRoot,
583
+ client: mockClient,
584
+ });
585
+ const chatMessage = plugin['chat.message'];
586
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
587
+ const services = getOrCreatePluginServices(tempRoot);
588
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
589
+ const store = new TeamStateStore();
590
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
591
+ ...team,
592
+ engineers: team.engineers.map((eng) => {
593
+ if (eng.name === 'Tom') {
594
+ return {
595
+ ...eng,
596
+ wrapperSessionId: 'wrapper-tom',
597
+ claudeSessionId: 'ses-claude-tom',
598
+ wrapperHistory: [
599
+ makeWrapperEntry('assignment', afterCutoff),
600
+ makeWrapperEntry('result', afterCutoff),
601
+ ],
602
+ };
603
+ }
604
+ if (eng.name === 'John') {
605
+ return {
606
+ ...eng,
607
+ wrapperSessionId: 'wrapper-john',
608
+ claudeSessionId: 'ses-claude-john',
609
+ wrapperHistory: [
610
+ makeWrapperEntry('assignment', afterCutoff),
611
+ makeWrapperEntry('result', afterCutoff),
612
+ ],
613
+ };
614
+ }
615
+ return eng;
616
+ }),
617
+ }));
618
+ const mockRunTask = vi
619
+ .spyOn(services.sessions, 'runTask')
620
+ .mockResolvedValue({ sessionId: undefined, events: [], finalText: '' });
621
+ // Event hook must not throw even when Tom's wrapper session fetch fails
622
+ await expect(plugin.event({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') })).resolves.toBeUndefined();
623
+ // John's /undo was still attempted despite Tom's failure
624
+ const undoCalls = mockRunTask.mock.calls.filter(([input]) => input.prompt === '/undo');
625
+ expect(undoCalls.some(([input]) => input.resumeSessionId === 'ses-claude-john')).toBe(true);
626
+ });
627
+ it('runs inner /undo and history prune for an engineer even when wrapper session revert throws', async () => {
628
+ const ctoMessageTimestamp = 1_500_000;
629
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
630
+ // messages() throws so revertWrapperSession throws inside step 1
631
+ const mockClient = {
632
+ session: {
633
+ get: vi.fn().mockResolvedValue({ data: undefined }),
634
+ message: vi.fn().mockResolvedValue({
635
+ data: {
636
+ info: { id: 'msg-rev-1', role: 'user', time: { created: ctoMessageTimestamp } },
637
+ parts: [],
638
+ },
639
+ }),
640
+ messages: vi.fn().mockRejectedValue(new Error('wrapper fetch failed')),
641
+ revert: vi.fn().mockResolvedValue({ data: {} }),
642
+ },
643
+ };
644
+ const plugin = await ClaudeManagerPlugin({
645
+ worktree: tempRoot,
646
+ client: mockClient,
647
+ });
648
+ const chatMessage = plugin['chat.message'];
649
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
650
+ const services = getOrCreatePluginServices(tempRoot);
651
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
652
+ const store = new TeamStateStore();
653
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
654
+ ...team,
655
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
656
+ ? {
657
+ ...eng,
658
+ wrapperSessionId: 'wrapper-tom',
659
+ claudeSessionId: 'ses-claude-tom',
660
+ wrapperHistory: [
661
+ makeWrapperEntry('assignment', afterCutoff),
662
+ makeWrapperEntry('result', afterCutoff),
663
+ ],
664
+ }
665
+ : eng),
666
+ }));
667
+ const mockRunTask = vi
668
+ .spyOn(services.sessions, 'runTask')
669
+ .mockResolvedValue({ sessionId: 'ses-claude-tom', events: [], finalText: '' });
670
+ // Event hook must not throw
671
+ await expect(plugin.event({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') })).resolves.toBeUndefined();
672
+ // Step 2: /undo was still sent despite step 1 failing
673
+ const undoCalls = mockRunTask.mock.calls.filter(([input]) => input.prompt === '/undo');
674
+ expect(undoCalls.some(([input]) => input.resumeSessionId === 'ses-claude-tom')).toBe(true);
675
+ // Step 3: wrapper history was pruned
676
+ const team = await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
677
+ const tom = team.engineers.find((e) => e.name === 'Tom');
678
+ expect(tom?.wrapperHistory).toHaveLength(0);
679
+ });
680
+ it('event hook resolves (does not throw) when CTO message fetch fails', async () => {
681
+ const mockClient = {
682
+ session: {
683
+ get: vi.fn().mockResolvedValue({ data: undefined }),
684
+ message: vi.fn().mockRejectedValue(new Error('network error')),
685
+ messages: vi.fn().mockResolvedValue({ data: [] }),
686
+ revert: vi.fn(),
687
+ },
688
+ };
689
+ const plugin = await ClaudeManagerPlugin({
690
+ worktree: tempRoot,
691
+ client: mockClient,
692
+ });
693
+ const chatMessage = plugin['chat.message'];
694
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
695
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
696
+ await expect(plugin.event({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-err') })).resolves.toBeUndefined();
697
+ // Dedup marker must be cleared so the event can be retried after the network recovers.
698
+ expect(isRevertAlreadyProcessed('cto-1', 'msg-rev-err')).toBe(false);
699
+ });
700
+ it('clears the dedup marker when cutoff resolution returns no timestamp', async () => {
701
+ const mockClient = {
702
+ session: {
703
+ get: vi.fn().mockResolvedValue({ data: undefined }),
704
+ // Returns a message with no time.created field
705
+ message: vi
706
+ .fn()
707
+ .mockResolvedValue({
708
+ data: { info: { id: 'msg-no-ts', role: 'user', time: {} }, parts: [] },
709
+ }),
710
+ messages: vi.fn().mockResolvedValue({ data: [] }),
711
+ revert: vi.fn(),
712
+ },
713
+ };
714
+ const plugin = await ClaudeManagerPlugin({
715
+ worktree: tempRoot,
716
+ client: mockClient,
717
+ });
718
+ const chatMessage = plugin['chat.message'];
719
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
720
+ await getOrCreatePluginServices(tempRoot).orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
721
+ await expect(plugin.event({ event: makeSessionUpdatedEvent('cto-1', 'msg-no-ts') })).resolves.toBeUndefined();
722
+ // Missing timestamp is a non-permanent failure; marker must be cleared for retry.
723
+ expect(isRevertAlreadyProcessed('cto-1', 'msg-no-ts')).toBe(false);
724
+ });
725
+ it('resets the engineer Claude session reference and context snapshot when inner /undo fails', async () => {
726
+ const ctoMessageTimestamp = 1_500_000;
727
+ const afterCutoff = new Date(ctoMessageTimestamp + 1000).toISOString();
728
+ const mockClient = buildMockClient({ ctoMessageTimestamp });
729
+ const plugin = await ClaudeManagerPlugin({
730
+ worktree: tempRoot,
731
+ client: mockClient,
732
+ });
733
+ const chatMessage = plugin['chat.message'];
734
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-1' });
735
+ const services = getOrCreatePluginServices(tempRoot);
736
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
737
+ // Seed Tom with a non-null claudeSessionId and a non-empty context snapshot.
738
+ const store = new TeamStateStore();
739
+ await store.updateTeam(tempRoot, 'cto-1', (team) => ({
740
+ ...team,
741
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
742
+ ? {
743
+ ...eng,
744
+ claudeSessionId: 'ses-claude-tom',
745
+ wrapperHistory: [
746
+ makeWrapperEntry('assignment', afterCutoff),
747
+ makeWrapperEntry('result', afterCutoff),
748
+ ],
749
+ context: {
750
+ sessionId: 'ses-claude-tom',
751
+ totalTurns: 5,
752
+ totalCostUsd: 0.12,
753
+ latestInputTokens: 1000,
754
+ latestOutputTokens: 500,
755
+ contextWindowSize: 200000,
756
+ estimatedContextPercent: 1,
757
+ warningLevel: 'ok',
758
+ compactionCount: 0,
759
+ },
760
+ }
761
+ : eng),
762
+ }));
763
+ // /undo always fails
764
+ vi.spyOn(services.sessions, 'runTask').mockRejectedValue(new Error('undo unavailable'));
765
+ await expect(plugin.event({ event: makeSessionUpdatedEvent('cto-1', 'msg-rev-1') })).resolves.toBeUndefined();
766
+ // Both claudeSessionId and context snapshot must be cleared so the next
767
+ // assignment starts a truly fresh session with no stale context metrics.
768
+ const team = await services.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
769
+ const tom = team.engineers.find((e) => e.name === 'Tom');
770
+ expect(tom?.claudeSessionId).toBeNull();
771
+ expect(tom?.context.sessionId).toBeNull();
772
+ expect(tom?.context.totalTurns).toBe(0);
773
+ expect(tom?.context.totalCostUsd).toBe(0);
774
+ });
775
+ });
776
+ // ---------------------------------------------------------------------------
777
+ // pruneWrapperHistoryAfter (orchestrator unit tests)
778
+ // ---------------------------------------------------------------------------
779
+ describe('TeamOrchestrator.pruneWrapperHistoryAfter', () => {
780
+ let tempRoot;
781
+ afterEach(async () => {
782
+ if (tempRoot)
783
+ await rm(tempRoot, { recursive: true, force: true });
784
+ });
785
+ it('removes entries with timestamp strictly after the cutoff', async () => {
786
+ tempRoot = await mkdtemp(join(tmpdir(), 'prune-history-'));
787
+ clearPluginServices();
788
+ const services = getOrCreatePluginServices(tempRoot);
789
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'team-1');
790
+ const cutoffIso = new Date(2_000_000).toISOString();
791
+ const before = new Date(1_000_000).toISOString();
792
+ const exact = cutoffIso;
793
+ const after = new Date(3_000_000).toISOString();
794
+ const store = new TeamStateStore();
795
+ await store.updateTeam(tempRoot, 'team-1', (team) => ({
796
+ ...team,
797
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
798
+ ? {
799
+ ...eng,
800
+ wrapperHistory: [
801
+ makeWrapperEntry('assignment', before),
802
+ makeWrapperEntry('result', before),
803
+ makeWrapperEntry('assignment', exact),
804
+ makeWrapperEntry('result', exact),
805
+ makeWrapperEntry('assignment', after),
806
+ makeWrapperEntry('result', after),
807
+ ],
808
+ }
809
+ : eng),
810
+ }));
811
+ await services.orchestrator.pruneWrapperHistoryAfter(tempRoot, 'team-1', 'Tom', cutoffIso);
812
+ const team = await services.orchestrator.getOrCreateTeam(tempRoot, 'team-1');
813
+ const tom = team.engineers.find((e) => e.name === 'Tom');
814
+ // Entries at or before the cutoff remain; entries strictly after are removed.
815
+ expect(tom?.wrapperHistory).toHaveLength(4);
816
+ expect(tom?.wrapperHistory.some((h) => h.timestamp > cutoffIso)).toBe(false);
817
+ });
818
+ it('is a no-op when all entries are at or before the cutoff', async () => {
819
+ tempRoot = await mkdtemp(join(tmpdir(), 'prune-noop-'));
820
+ clearPluginServices();
821
+ const services = getOrCreatePluginServices(tempRoot);
822
+ await services.orchestrator.getOrCreateTeam(tempRoot, 'team-1');
823
+ const store = new TeamStateStore();
824
+ const early = new Date(500_000).toISOString();
825
+ await store.updateTeam(tempRoot, 'team-1', (team) => ({
826
+ ...team,
827
+ engineers: team.engineers.map((eng) => eng.name === 'Tom'
828
+ ? { ...eng, wrapperHistory: [makeWrapperEntry('assignment', early)] }
829
+ : eng),
830
+ }));
831
+ const cutoffIso = new Date(2_000_000).toISOString();
832
+ await services.orchestrator.pruneWrapperHistoryAfter(tempRoot, 'team-1', 'Tom', cutoffIso);
833
+ const team = await services.orchestrator.getOrCreateTeam(tempRoot, 'team-1');
834
+ const tom = team.engineers.find((e) => e.name === 'Tom');
835
+ expect(tom?.wrapperHistory).toHaveLength(1);
836
+ });
837
+ });