@doingdev/opencode-claude-manager-plugin 0.1.64 → 0.1.66
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 +106 -120
- package/dist/claude/claude-agent-sdk-adapter.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/manager/team-orchestrator.js +1 -1
- package/dist/plugin/agents/common.d.ts +2 -2
- package/dist/plugin/agents/common.js +5 -0
- package/dist/plugin/claude-manager.plugin.js +104 -0
- package/dist/plugin/inbox-ops.d.ts +50 -0
- package/dist/plugin/inbox-ops.js +166 -0
- package/dist/types/contracts.d.ts +18 -0
- package/package.json +13 -13
- package/dist/claude/session-live-tailer.d.ts +0 -51
- package/dist/claude/session-live-tailer.js +0 -269
- package/dist/manager/session-controller.d.ts +0 -41
- package/dist/manager/session-controller.js +0 -97
- package/dist/metadata/claude-metadata.service.d.ts +0 -12
- package/dist/metadata/claude-metadata.service.js +0 -38
- package/dist/metadata/repo-claude-config-reader.d.ts +0 -7
- package/dist/metadata/repo-claude-config-reader.js +0 -154
- package/dist/plugin/orchestrator.plugin.d.ts +0 -2
- package/dist/plugin/orchestrator.plugin.js +0 -116
- package/dist/providers/claude-code-wrapper.d.ts +0 -13
- package/dist/providers/claude-code-wrapper.js +0 -13
- package/dist/safety/bash-safety.d.ts +0 -21
- package/dist/safety/bash-safety.js +0 -62
- package/dist/src/claude/claude-agent-sdk-adapter.d.ts +0 -28
- package/dist/src/claude/claude-agent-sdk-adapter.js +0 -559
- package/dist/src/claude/claude-session.service.d.ts +0 -9
- package/dist/src/claude/claude-session.service.js +0 -15
- package/dist/src/claude/session-live-tailer.d.ts +0 -51
- package/dist/src/claude/session-live-tailer.js +0 -269
- package/dist/src/claude/tool-approval-manager.d.ts +0 -30
- package/dist/src/claude/tool-approval-manager.js +0 -279
- package/dist/src/index.d.ts +0 -5
- package/dist/src/index.js +0 -3
- package/dist/src/manager/context-tracker.d.ts +0 -32
- package/dist/src/manager/context-tracker.js +0 -103
- package/dist/src/manager/git-operations.d.ts +0 -18
- package/dist/src/manager/git-operations.js +0 -86
- package/dist/src/manager/persistent-manager.d.ts +0 -39
- package/dist/src/manager/persistent-manager.js +0 -44
- package/dist/src/manager/session-controller.d.ts +0 -41
- package/dist/src/manager/session-controller.js +0 -97
- package/dist/src/manager/team-orchestrator.d.ts +0 -81
- package/dist/src/manager/team-orchestrator.js +0 -612
- package/dist/src/plugin/agent-hierarchy.d.ts +0 -1
- package/dist/src/plugin/agent-hierarchy.js +0 -2
- package/dist/src/plugin/agents/browser-qa.d.ts +0 -14
- package/dist/src/plugin/agents/browser-qa.js +0 -31
- package/dist/src/plugin/agents/common.d.ts +0 -36
- package/dist/src/plugin/agents/common.js +0 -59
- package/dist/src/plugin/agents/cto.d.ts +0 -9
- package/dist/src/plugin/agents/cto.js +0 -39
- package/dist/src/plugin/agents/engineers.d.ts +0 -9
- package/dist/src/plugin/agents/engineers.js +0 -11
- package/dist/src/plugin/agents/index.d.ts +0 -5
- package/dist/src/plugin/agents/index.js +0 -5
- package/dist/src/plugin/agents/team-planner.d.ts +0 -10
- package/dist/src/plugin/agents/team-planner.js +0 -23
- package/dist/src/plugin/claude-manager.plugin.d.ts +0 -10
- package/dist/src/plugin/claude-manager.plugin.js +0 -950
- package/dist/src/plugin/service-factory.d.ts +0 -38
- package/dist/src/plugin/service-factory.js +0 -101
- package/dist/src/prompts/registry.d.ts +0 -2
- package/dist/src/prompts/registry.js +0 -210
- package/dist/src/state/file-run-state-store.d.ts +0 -14
- package/dist/src/state/file-run-state-store.js +0 -85
- package/dist/src/state/team-state-store.d.ts +0 -14
- package/dist/src/state/team-state-store.js +0 -88
- package/dist/src/state/transcript-store.d.ts +0 -15
- package/dist/src/state/transcript-store.js +0 -44
- package/dist/src/team/roster.d.ts +0 -5
- package/dist/src/team/roster.js +0 -40
- package/dist/src/types/contracts.d.ts +0 -261
- package/dist/src/types/contracts.js +0 -2
- package/dist/src/util/fs-helpers.d.ts +0 -8
- package/dist/src/util/fs-helpers.js +0 -21
- package/dist/src/util/project-context.d.ts +0 -10
- package/dist/src/util/project-context.js +0 -105
- package/dist/src/util/transcript-append.d.ts +0 -7
- package/dist/src/util/transcript-append.js +0 -29
- package/dist/state/file-run-state-store.d.ts +0 -14
- package/dist/state/file-run-state-store.js +0 -85
- package/dist/test/claude-agent-sdk-adapter.test.d.ts +0 -1
- package/dist/test/claude-agent-sdk-adapter.test.js +0 -707
- package/dist/test/claude-manager.plugin.test.d.ts +0 -1
- package/dist/test/claude-manager.plugin.test.js +0 -316
- package/dist/test/context-tracker.test.d.ts +0 -1
- package/dist/test/context-tracker.test.js +0 -130
- package/dist/test/cto-active-team.test.d.ts +0 -1
- package/dist/test/cto-active-team.test.js +0 -199
- package/dist/test/file-run-state-store.test.d.ts +0 -1
- package/dist/test/file-run-state-store.test.js +0 -82
- package/dist/test/fs-helpers.test.d.ts +0 -1
- package/dist/test/fs-helpers.test.js +0 -56
- package/dist/test/git-operations.test.d.ts +0 -1
- package/dist/test/git-operations.test.js +0 -133
- package/dist/test/persistent-manager.test.d.ts +0 -1
- package/dist/test/persistent-manager.test.js +0 -48
- package/dist/test/project-context.test.d.ts +0 -1
- package/dist/test/project-context.test.js +0 -92
- package/dist/test/prompt-registry.test.d.ts +0 -1
- package/dist/test/prompt-registry.test.js +0 -117
- package/dist/test/report-claude-event.test.d.ts +0 -1
- package/dist/test/report-claude-event.test.js +0 -304
- package/dist/test/session-controller.test.d.ts +0 -1
- package/dist/test/session-controller.test.js +0 -149
- package/dist/test/session-live-tailer.test.d.ts +0 -1
- package/dist/test/session-live-tailer.test.js +0 -313
- package/dist/test/team-orchestrator.test.d.ts +0 -1
- package/dist/test/team-orchestrator.test.js +0 -583
- package/dist/test/team-state-store.test.d.ts +0 -1
- package/dist/test/team-state-store.test.js +0 -54
- package/dist/test/tool-approval-manager.test.d.ts +0 -1
- package/dist/test/tool-approval-manager.test.js +0 -260
- package/dist/test/transcript-append.test.d.ts +0 -1
- package/dist/test/transcript-append.test.js +0 -37
- package/dist/test/transcript-store.test.d.ts +0 -1
- package/dist/test/transcript-store.test.js +0 -50
- package/dist/test/undo-propagation.test.d.ts +0 -1
- package/dist/test/undo-propagation.test.js +0 -837
- package/dist/util/project-context.d.ts +0 -10
- package/dist/util/project-context.js +0 -105
- package/dist/vitest.config.d.ts +0 -2
- package/dist/vitest.config.js +0 -11
|
@@ -1,837 +0,0 @@
|
|
|
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
|
-
});
|