@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.
Files changed (44) hide show
  1. package/.env.example +7 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
  3. package/.github/workflows/changelog.yml +32 -0
  4. package/.github/workflows/publish.yml +46 -0
  5. package/CHANGELOG.md +140 -0
  6. package/CONTRIBUTING.md +126 -0
  7. package/HEADLESS-FLEET-PLAN.md +1 -1
  8. package/README.md +52 -5
  9. package/UNIFIED-TREE-PLAN.md +2 -0
  10. package/docs/AGENT-ONBOARDING.md +9 -8
  11. package/docs/DEPLOYMENTS.md +2 -2
  12. package/docs/DEV-ENVIRONMENT.md +28 -26
  13. package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
  14. package/package.json +5 -4
  15. package/scripts/release-changelog.ts +32 -0
  16. package/src/codex-subscription-adapter.ts +938 -0
  17. package/src/commands.ts +95 -4
  18. package/src/extensions.ts +201 -0
  19. package/src/framework-agent-config.ts +20 -9
  20. package/src/framework-strategy.ts +24 -0
  21. package/src/index.ts +111 -19
  22. package/src/logging-bedrock-adapter.ts +145 -0
  23. package/src/modules/channel-mode-module.ts +9 -6
  24. package/src/modules/subscription-gc-module.ts +163 -34
  25. package/src/modules/tts-relay-module.ts +481 -0
  26. package/src/modules/web-ui-module.ts +45 -1
  27. package/src/recipe.ts +196 -10
  28. package/src/tui.ts +527 -137
  29. package/src/web/protocol.ts +35 -0
  30. package/test/codex-subscription-adapter.test.ts +226 -0
  31. package/test/commands-checkpoint-restore.test.ts +118 -0
  32. package/test/fast-command.test.ts +39 -0
  33. package/test/recipe-extensions.test.ts +295 -0
  34. package/test/recipe-provider.test.ts +14 -1
  35. package/test/subscription-gc-module.test.ts +156 -1
  36. package/test/tui-quit-confirm.test.ts +42 -0
  37. package/test/tui-short-agent-name.test.ts +36 -0
  38. package/test/web-ui-observers.test.ts +14 -0
  39. package/test/web-ui-protocol.test.ts +0 -0
  40. package/web/src/App.tsx +235 -20
  41. package/web/src/Branches.tsx +150 -0
  42. package/web/src/Context.tsx +21 -13
  43. package/web/src/Health.tsx +274 -0
  44. package/web/src/Lessons.tsx +2 -1
@@ -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
+ });