@animalabs/connectome-host 0.3.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +140 -0
- package/CONTRIBUTING.md +126 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/README.md +52 -5
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +5 -4
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +95 -4
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +20 -9
- package/src/framework-strategy.ts +24 -0
- package/src/index.ts +111 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +196 -10
- package/src/tui.ts +527 -137
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
package/src/web/protocol.ts
CHANGED
|
@@ -280,6 +280,31 @@ export interface BranchChangedMessage {
|
|
|
280
280
|
branch: { id: string; name: string };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/** One Chronicle branch with its lineage. `parentId`/`branchPoint` are
|
|
284
|
+
* undefined for the root branch; together they place the fork in the
|
|
285
|
+
* parent's sequence so the SPA can render a lineage tree. */
|
|
286
|
+
export interface BranchRow {
|
|
287
|
+
id: string;
|
|
288
|
+
name: string;
|
|
289
|
+
/** Head sequence number of the branch. */
|
|
290
|
+
head: number;
|
|
291
|
+
parentId?: string;
|
|
292
|
+
/** Sequence in the PARENT at which this branch forked. */
|
|
293
|
+
branchPoint?: number;
|
|
294
|
+
/** Epoch millis creation time. */
|
|
295
|
+
created: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Full branch listing with lineage — response to `request-branches`, routed
|
|
299
|
+
* only to the requesting client. The SPA refreshes it after every
|
|
300
|
+
* `branch-changed` while its branch panel is open. */
|
|
301
|
+
export interface BranchesListMessage {
|
|
302
|
+
type: 'branches-list';
|
|
303
|
+
branches: BranchRow[];
|
|
304
|
+
/** id of the branch the agent is currently on. */
|
|
305
|
+
currentId: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
283
308
|
/** Sent when the active session changes. Clients should soft-reconnect. */
|
|
284
309
|
export interface SessionChangedMessage {
|
|
285
310
|
type: 'session-changed';
|
|
@@ -442,6 +467,7 @@ export type WebUiServerMessage =
|
|
|
442
467
|
| UsageMessage
|
|
443
468
|
| CallLedgerMessage
|
|
444
469
|
| BranchChangedMessage
|
|
470
|
+
| BranchesListMessage
|
|
445
471
|
| SessionChangedMessage
|
|
446
472
|
| PeekMessage
|
|
447
473
|
| InboundTriggerMessage
|
|
@@ -576,6 +602,13 @@ export interface RequestMcplMessage {
|
|
|
576
602
|
type: 'request-mcpl';
|
|
577
603
|
}
|
|
578
604
|
|
|
605
|
+
/** Pull the Chronicle branch listing. Response is a `branches-list` envelope
|
|
606
|
+
* routed only to the requesting client. Read-only; switching branches goes
|
|
607
|
+
* through the `/checkout` command (full-auth clients only). */
|
|
608
|
+
export interface RequestBranchesMessage {
|
|
609
|
+
type: 'request-branches';
|
|
610
|
+
}
|
|
611
|
+
|
|
579
612
|
/** Add or overwrite an MCPL server entry in mcpl-servers.json. Restart is
|
|
580
613
|
* required for the host to pick up the change; the response is a fresh
|
|
581
614
|
* `mcpl-list` so the SPA reflects the new state. */
|
|
@@ -669,6 +702,7 @@ export type WebUiClientMessage =
|
|
|
669
702
|
| QuitConfirmMessage
|
|
670
703
|
| RequestLessonsMessage
|
|
671
704
|
| RequestMcplMessage
|
|
705
|
+
| RequestBranchesMessage
|
|
672
706
|
| McplAddMessage
|
|
673
707
|
| McplRemoveMessage
|
|
674
708
|
| McplSetEnvMessage
|
|
@@ -696,6 +730,7 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
696
730
|
case 'ping':
|
|
697
731
|
case 'interrupt':
|
|
698
732
|
case 'request-mcpl':
|
|
733
|
+
case 'request-branches':
|
|
699
734
|
return true;
|
|
700
735
|
case 'user-message':
|
|
701
736
|
return typeof v.content === 'string';
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ProviderRequest } from '@animalabs/membrane';
|
|
3
|
+
import {
|
|
4
|
+
CodexSubscriptionAdapter,
|
|
5
|
+
type CodexAuthProvider,
|
|
6
|
+
} from '../src/codex-subscription-adapter.js';
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
globalThis.fetch = originalFetch;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function request(extra: Record<string, unknown> = {}): ProviderRequest {
|
|
15
|
+
return {
|
|
16
|
+
model: 'gpt-5.4',
|
|
17
|
+
messages: [{ type: 'message', role: 'user', content: 'Hello' }],
|
|
18
|
+
maxTokens: 8192,
|
|
19
|
+
temperature: 0.2,
|
|
20
|
+
topP: 0.9,
|
|
21
|
+
topK: 20,
|
|
22
|
+
extra,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function completedResponse(): Response {
|
|
27
|
+
const item = {
|
|
28
|
+
type: 'message',
|
|
29
|
+
id: 'msg_test',
|
|
30
|
+
role: 'assistant',
|
|
31
|
+
content: [{ type: 'output_text', text: 'Hello back' }],
|
|
32
|
+
};
|
|
33
|
+
const events = [
|
|
34
|
+
{ type: 'response.output_item.done', output_index: 0, item },
|
|
35
|
+
{
|
|
36
|
+
type: 'response.completed',
|
|
37
|
+
response: {
|
|
38
|
+
id: 'resp_test',
|
|
39
|
+
model: 'gpt-5.4',
|
|
40
|
+
status: 'completed',
|
|
41
|
+
output: [],
|
|
42
|
+
usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), {
|
|
47
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('CodexSubscriptionAdapter', () => {
|
|
52
|
+
test('uses the subscription endpoint and enables Fast mode per request', async () => {
|
|
53
|
+
const requests: Array<{ url: string; headers: Headers; body: Record<string, unknown> }> = [];
|
|
54
|
+
globalThis.fetch = async (input, init) => {
|
|
55
|
+
requests.push({
|
|
56
|
+
url: String(input),
|
|
57
|
+
headers: new Headers(init?.headers),
|
|
58
|
+
body: JSON.parse(String(init?.body)),
|
|
59
|
+
});
|
|
60
|
+
return completedResponse();
|
|
61
|
+
};
|
|
62
|
+
const auth: CodexAuthProvider = {
|
|
63
|
+
getAccessToken: async () => 'subscription-token',
|
|
64
|
+
getAccountId: () => 'account-test',
|
|
65
|
+
};
|
|
66
|
+
const adapter = new CodexSubscriptionAdapter({
|
|
67
|
+
authProvider: auth,
|
|
68
|
+
baseURL: 'https://example.test/backend-api/codex/',
|
|
69
|
+
fastMode: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const response = await adapter.complete(request({ max_output_tokens: 999 }));
|
|
73
|
+
|
|
74
|
+
expect(response.stopReason).toBe('end_turn');
|
|
75
|
+
expect((response.content as Array<{ text?: string }>)[0]?.text).toBe('Hello back');
|
|
76
|
+
expect(requests).toHaveLength(1);
|
|
77
|
+
expect(requests[0]?.url).toBe('https://example.test/backend-api/codex/responses');
|
|
78
|
+
expect(requests[0]?.headers.get('authorization')).toBe('Bearer subscription-token');
|
|
79
|
+
expect(requests[0]?.headers.get('chatgpt-account-id')).toBe('account-test');
|
|
80
|
+
expect(requests[0]?.body.service_tier).toBe('priority');
|
|
81
|
+
expect(requests[0]?.body.max_output_tokens).toBeUndefined();
|
|
82
|
+
expect(requests[0]?.body.temperature).toBeUndefined();
|
|
83
|
+
expect(requests[0]?.body.top_p).toBeUndefined();
|
|
84
|
+
expect(requests[0]?.body.input).toEqual([{
|
|
85
|
+
type: 'message',
|
|
86
|
+
role: 'user',
|
|
87
|
+
content: [{ type: 'input_text', text: 'Hello' }],
|
|
88
|
+
}]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('normalizes maintenance text, images, and tool blocks at the transport boundary', async () => {
|
|
92
|
+
let body: Record<string, any> = {};
|
|
93
|
+
globalThis.fetch = async (_input, init) => {
|
|
94
|
+
body = JSON.parse(String(init?.body));
|
|
95
|
+
return completedResponse();
|
|
96
|
+
};
|
|
97
|
+
const adapter = new CodexSubscriptionAdapter({
|
|
98
|
+
authProvider: { getAccessToken: async () => 'subscription-token' },
|
|
99
|
+
baseURL: 'https://example.test/codex',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await adapter.complete({
|
|
103
|
+
model: 'gpt-5.4',
|
|
104
|
+
messages: [
|
|
105
|
+
{
|
|
106
|
+
type: 'message', role: 'user', content: [
|
|
107
|
+
{ type: 'text', text: 'inspect' },
|
|
108
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'message', role: 'assistant', content: [
|
|
113
|
+
{ type: 'tool_use', id: 'call_1', name: 'lookup', input: { q: 'x' } },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: 'message', role: 'user', content: [
|
|
118
|
+
{ type: 'tool_result', tool_use_id: 'call_1', content: 'found' },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
] as any,
|
|
122
|
+
maxTokens: 1024,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(body.input).toEqual([
|
|
126
|
+
{
|
|
127
|
+
type: 'message', role: 'user', content: [
|
|
128
|
+
{ type: 'input_text', text: 'inspect' },
|
|
129
|
+
{ type: 'input_image', image_url: 'data:image/png;base64,aGVsbG8=' },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{ type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"q":"x"}' },
|
|
133
|
+
{ type: 'function_call_output', call_id: 'call_1', output: 'found' },
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('turns Fast mode off without reconstructing the adapter', async () => {
|
|
138
|
+
const bodies: Record<string, unknown>[] = [];
|
|
139
|
+
globalThis.fetch = async (_input, init) => {
|
|
140
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
141
|
+
return completedResponse();
|
|
142
|
+
};
|
|
143
|
+
const adapter = new CodexSubscriptionAdapter({
|
|
144
|
+
authProvider: { getAccessToken: async () => 'subscription-token' },
|
|
145
|
+
baseURL: 'https://example.test/codex',
|
|
146
|
+
fastMode: true,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await adapter.complete(request());
|
|
150
|
+
adapter.setFastMode(false);
|
|
151
|
+
await adapter.complete(request({ service_tier: 'priority' }));
|
|
152
|
+
|
|
153
|
+
expect(bodies[0]?.service_tier).toBe('priority');
|
|
154
|
+
expect(bodies[1]?.service_tier).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('refreshes the ChatGPT token once after a 401', async () => {
|
|
158
|
+
const refreshFlags: boolean[] = [];
|
|
159
|
+
let calls = 0;
|
|
160
|
+
globalThis.fetch = async () => {
|
|
161
|
+
calls += 1;
|
|
162
|
+
if (calls === 1) return new Response('expired', { status: 401 });
|
|
163
|
+
return completedResponse();
|
|
164
|
+
};
|
|
165
|
+
const adapter = new CodexSubscriptionAdapter({
|
|
166
|
+
authProvider: {
|
|
167
|
+
getAccessToken: async (forceRefresh = false) => {
|
|
168
|
+
refreshFlags.push(forceRefresh);
|
|
169
|
+
return forceRefresh ? 'fresh-token' : 'expired-token';
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
baseURL: 'https://example.test/codex',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await adapter.complete(request());
|
|
176
|
+
|
|
177
|
+
expect(calls).toBe(2);
|
|
178
|
+
expect(refreshFlags).toEqual([false, true]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('reconstructs tool calls when the terminal event has an empty output', async () => {
|
|
182
|
+
const item = {
|
|
183
|
+
type: 'function_call',
|
|
184
|
+
id: 'fc_test',
|
|
185
|
+
call_id: 'call_test',
|
|
186
|
+
name: 'lookup',
|
|
187
|
+
arguments: '{"query":"connectome"}',
|
|
188
|
+
};
|
|
189
|
+
globalThis.fetch = async () => new Response([
|
|
190
|
+
`data: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item })}\n\n`,
|
|
191
|
+
`data: ${JSON.stringify({
|
|
192
|
+
type: 'response.completed',
|
|
193
|
+
response: {
|
|
194
|
+
model: 'gpt-5.4', status: 'completed', output: [],
|
|
195
|
+
usage: { input_tokens: 2, output_tokens: 3 },
|
|
196
|
+
},
|
|
197
|
+
})}\n\n`,
|
|
198
|
+
].join(''), { headers: { 'Content-Type': 'text/event-stream' } });
|
|
199
|
+
const adapter = new CodexSubscriptionAdapter({
|
|
200
|
+
authProvider: { getAccessToken: async () => 'subscription-token' },
|
|
201
|
+
baseURL: 'https://example.test/codex',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const response = await adapter.complete(request());
|
|
205
|
+
|
|
206
|
+
expect(response.stopReason).toBe('tool_use');
|
|
207
|
+
expect(response.content).toEqual([expect.objectContaining({
|
|
208
|
+
type: 'tool_use', id: 'call_test', name: 'lookup', input: { query: 'connectome' },
|
|
209
|
+
})]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('surfaces nested SSE error details', async () => {
|
|
213
|
+
globalThis.fetch = async () => new Response(
|
|
214
|
+
'data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input is too large"}}\n\n',
|
|
215
|
+
{ headers: { 'Content-Type': 'text/event-stream' } },
|
|
216
|
+
);
|
|
217
|
+
const adapter = new CodexSubscriptionAdapter({
|
|
218
|
+
authProvider: { getAccessToken: async () => 'subscription-token' },
|
|
219
|
+
baseURL: 'https://example.test/codex',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await expect(adapter.complete(request())).rejects.toThrow(
|
|
223
|
+
/context_length_exceeded.*input is too large/,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { handleCommand, createBranchState } from '../src/commands.js';
|
|
3
|
+
|
|
4
|
+
// Regression tests for /checkpoint + /restore POSITION semantics. The
|
|
5
|
+
// original bug: checkpoints stored only a branch name, so /restore switched
|
|
6
|
+
// to the branch HEAD — including everything after the checkpoint — rolling
|
|
7
|
+
// back nothing, silently. These tests run over a stub ContextManager that
|
|
8
|
+
// mimics Chronicle's contract: branchAt resolves ids against the CURRENT
|
|
9
|
+
// branch's view of the log and throws for unreachable ids.
|
|
10
|
+
|
|
11
|
+
interface StubMessage { id: string; participant: string; content: unknown[] }
|
|
12
|
+
|
|
13
|
+
function makeStubWorld() {
|
|
14
|
+
const messagesByBranch = new Map<string, StubMessage[]>([['main', []]]);
|
|
15
|
+
let currentName = 'main';
|
|
16
|
+
let msgCounter = 0;
|
|
17
|
+
|
|
18
|
+
const cm = {
|
|
19
|
+
currentBranch: () => ({ id: currentName, name: currentName, head: messagesByBranch.get(currentName)!.length }),
|
|
20
|
+
listBranches: () => [...messagesByBranch.keys()].map(name => ({ id: name, name, head: messagesByBranch.get(name)!.length })),
|
|
21
|
+
queryMessages: (_q: unknown) => ({ messages: messagesByBranch.get(currentName)! }),
|
|
22
|
+
branchAt: (messageId: string, newName: string): string => {
|
|
23
|
+
const msgs = messagesByBranch.get(currentName)!;
|
|
24
|
+
const idx = msgs.findIndex(m => m.id === messageId);
|
|
25
|
+
if (idx === -1) throw new Error(`Message not found: ${messageId}`);
|
|
26
|
+
messagesByBranch.set(newName, msgs.slice(0, idx + 1));
|
|
27
|
+
return newName;
|
|
28
|
+
},
|
|
29
|
+
switchBranch: async (name: string): Promise<void> => {
|
|
30
|
+
if (!messagesByBranch.has(name)) throw new Error(`No such branch: ${name}`);
|
|
31
|
+
currentName = name;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const addMessage = (participant = 'user'): StubMessage => {
|
|
36
|
+
const msg: StubMessage = { id: `m${++msgCounter}`, participant, content: [] };
|
|
37
|
+
messagesByBranch.get(currentName)!.push(msg);
|
|
38
|
+
return msg;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const app = {
|
|
42
|
+
framework: {
|
|
43
|
+
getAgent: () => undefined,
|
|
44
|
+
getAllAgents: () => [{ getContextManager: () => cm }],
|
|
45
|
+
getAllModules: () => [],
|
|
46
|
+
},
|
|
47
|
+
branchState: createBranchState(),
|
|
48
|
+
userMessageCount: 0,
|
|
49
|
+
} as any;
|
|
50
|
+
|
|
51
|
+
return { cm, app, addMessage, branchCount: () => messagesByBranch.size, currentName: () => currentName };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('/checkpoint + /restore position semantics', () => {
|
|
55
|
+
test('checkpoint records the last message id, not just the branch', () => {
|
|
56
|
+
const { app, addMessage } = makeStubWorld();
|
|
57
|
+
addMessage();
|
|
58
|
+
const last = addMessage('agent');
|
|
59
|
+
handleCommand('/checkpoint cp', app);
|
|
60
|
+
const point = app.branchState.checkpoints.get('cp');
|
|
61
|
+
expect(point.branchName).toBe('main');
|
|
62
|
+
expect(point.messageId).toBe(last.id);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('restore rolls back to the checkpoint position, not the branch head', async () => {
|
|
66
|
+
const { app, addMessage, currentName } = makeStubWorld();
|
|
67
|
+
addMessage();
|
|
68
|
+
const cpMsg = addMessage('agent');
|
|
69
|
+
handleCommand('/checkpoint cp', app);
|
|
70
|
+
addMessage();
|
|
71
|
+
addMessage('agent'); // work after the checkpoint that must be rolled back
|
|
72
|
+
|
|
73
|
+
const result = handleCommand('/restore cp', app);
|
|
74
|
+
const outcome = await result.asyncWork!;
|
|
75
|
+
expect(outcome.branchChanged).toBe(true);
|
|
76
|
+
expect(currentName().startsWith('restore-cp-')).toBe(true);
|
|
77
|
+
|
|
78
|
+
const { messages } = (app.framework.getAllAgents()[0].getContextManager() as any).queryMessages({});
|
|
79
|
+
expect(messages[messages.length - 1].id).toBe(cpMsg.id);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('restoring while already at the checkpoint is a no-op — no branch minted', async () => {
|
|
83
|
+
const { app, addMessage, branchCount } = makeStubWorld();
|
|
84
|
+
addMessage();
|
|
85
|
+
addMessage('agent');
|
|
86
|
+
handleCommand('/checkpoint cp', app);
|
|
87
|
+
addMessage();
|
|
88
|
+
|
|
89
|
+
await handleCommand('/restore cp', app).asyncWork!;
|
|
90
|
+
const branchesAfterFirst = branchCount();
|
|
91
|
+
|
|
92
|
+
// Second restore at the same position: the guard must hold even though
|
|
93
|
+
// we're now on restore-cp-… rather than the original branch (the guard
|
|
94
|
+
// used to compare branch names and died after the first restore,
|
|
95
|
+
// minting a sibling branch per repeat).
|
|
96
|
+
const second = handleCommand('/restore cp', app);
|
|
97
|
+
expect(second.asyncWork).toBeUndefined();
|
|
98
|
+
expect(second.lines[0]!.text).toContain('Already at checkpoint');
|
|
99
|
+
expect(branchCount()).toBe(branchesAfterFirst);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('unreachable checkpoint position degrades to the branch head with a note', async () => {
|
|
103
|
+
const { app, cm, addMessage, currentName } = makeStubWorld();
|
|
104
|
+
const early = addMessage();
|
|
105
|
+
addMessage('agent');
|
|
106
|
+
handleCommand('/checkpoint cp', app);
|
|
107
|
+
|
|
108
|
+
// Simulate /undo: branch truncated BEFORE the checkpoint message, so the
|
|
109
|
+
// checkpoint id is unreachable from the current branch's view.
|
|
110
|
+
const undoBranch = cm.branchAt(early.id, 'undo-1');
|
|
111
|
+
await cm.switchBranch(undoBranch);
|
|
112
|
+
|
|
113
|
+
const result = handleCommand('/restore cp', app);
|
|
114
|
+
const outcome = await result.asyncWork!;
|
|
115
|
+
expect(currentName()).toBe('main');
|
|
116
|
+
expect(outcome.lines.some(l => l.text.includes('unreachable'))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { AgentFramework } from '@animalabs/agent-framework';
|
|
3
|
+
import { handleCommand } from '../src/commands.js';
|
|
4
|
+
|
|
5
|
+
function app(adapter?: { enabled: boolean }) : Parameters<typeof handleCommand>[1] {
|
|
6
|
+
return {
|
|
7
|
+
framework: {} as AgentFramework,
|
|
8
|
+
sessionManager: {} as never,
|
|
9
|
+
recipe: { name: 'test' } as never,
|
|
10
|
+
branchState: {} as never,
|
|
11
|
+
codexAdapter: adapter ? {
|
|
12
|
+
isFastMode: () => adapter.enabled,
|
|
13
|
+
setFastMode: (enabled) => { adapter.enabled = enabled; },
|
|
14
|
+
} : undefined,
|
|
15
|
+
switchSession: async () => {},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('/fast', () => {
|
|
20
|
+
test('reports that the command requires the Codex subscription provider', () => {
|
|
21
|
+
expect(handleCommand('/fast on', app()).lines[0]?.text).toMatch(/openai-codex/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('toggles and reports Fast mode', () => {
|
|
25
|
+
const state = { enabled: false };
|
|
26
|
+
|
|
27
|
+
expect(handleCommand('/fast', app(state)).lines[0]?.text).toMatch(/OFF/);
|
|
28
|
+
expect(handleCommand('/fast on', app(state)).lines[0]?.text).toMatch(/requested/);
|
|
29
|
+
expect(state.enabled).toBe(true);
|
|
30
|
+
expect(handleCommand('/fast status', app(state)).lines[0]?.text).toMatch(/ON/);
|
|
31
|
+
expect(handleCommand('/fast off', app(state)).lines[0]?.text).toMatch(/disabled/);
|
|
32
|
+
expect(state.enabled).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('rejects unknown modes', () => {
|
|
36
|
+
expect(handleCommand('/fast turbo', app({ enabled: false })).lines[0]?.text)
|
|
37
|
+
.toBe('Usage: /fast [on|off|status]');
|
|
38
|
+
});
|
|
39
|
+
});
|