@canonmsg/codex-plugin 0.29.4 → 0.30.1

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,222 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Real local Codex protocol, fake localhost model and Canon publisher. No model
4
+ // API or Canon account calls. Build Core, SDK and this package before running.
5
+ import assert from 'node:assert/strict';
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+ import { createServer as httpServer } from 'node:http';
9
+ import { createServer as netServer } from 'node:net';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import WebSocket from 'ws';
13
+ import { createAttachedNativeSession, createFileAttachedSessionStore } from '@canonmsg/core';
14
+ import { createCanonAttachedSessionPublisher } from '@canonmsg/agent-sdk';
15
+ import { CodexAttachedSessionAdapter } from '../dist/attached-session-adapter.js';
16
+
17
+ const binary = process.argv[2] ?? 'codex';
18
+ const directory = await mkdtemp(join(tmpdir(), 'canon-attach-smoke-'));
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ async function until(predicate, description) {
21
+ for (let attempt = 0; attempt < 300; attempt += 1) {
22
+ if (await predicate()) return;
23
+ await sleep(50);
24
+ }
25
+ throw new Error(`Timed out: ${description}`);
26
+ }
27
+ async function listen(server) {
28
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
29
+ return server.address().port;
30
+ }
31
+
32
+ let providerCalls = 0;
33
+ let requestApprovalOnNextModelCall = false;
34
+ const provider = httpServer((request, response) => {
35
+ request.resume();
36
+ providerCalls += 1;
37
+ const item = requestApprovalOnNextModelCall ? {
38
+ id: 'approval_function', type: 'function_call', call_id: 'approval_call', name: 'exec_command',
39
+ arguments: JSON.stringify({
40
+ cmd: 'printf canon-approval-smoke', sandbox_permissions: 'require_escalated',
41
+ justification: 'Disposable approval routing check; the native client will decline.',
42
+ }),
43
+ } : {
44
+ id: `message_${providerCalls}`, type: 'message', role: 'assistant', status: 'completed',
45
+ content: [{ type: 'output_text', text: `Mock answer ${providerCalls}.`, annotations: [] }],
46
+ };
47
+ requestApprovalOnNextModelCall = false;
48
+ const result = {
49
+ id: `response_${providerCalls}`, object: 'response', model: 'probe', status: 'completed',
50
+ output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
51
+ };
52
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
53
+ for (const event of [
54
+ { type: 'response.created', response: { ...result, status: 'in_progress', output: [] } },
55
+ { type: 'response.output_item.added', output_index: 0, item: item.type === 'message' ? { ...item, status: 'in_progress', content: [] } : item },
56
+ ...(item.type === 'message' ? [{ type: 'response.output_text.delta', output_index: 0, content_index: 0, item_id: item.id, delta: item.content[0].text }] : []),
57
+ { type: 'response.output_item.done', output_index: 0, item },
58
+ { type: 'response.completed', response: result },
59
+ ]) response.write(`data: ${JSON.stringify(event)}\n\n`);
60
+ response.end();
61
+ });
62
+ const modelPort = await listen(provider);
63
+ const reservation = netServer();
64
+ const port = await listen(reservation);
65
+ await new Promise((resolve) => reservation.close(resolve));
66
+ const endpoint = `ws://127.0.0.1:${port}`;
67
+ const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _originatorOverride, ...serverEnv } = process.env;
68
+ const server = spawn(binary, ['app-server', '--disable', 'plugins', '--disable', 'apps', '--listen', endpoint], {
69
+ cwd: directory, env: serverEnv, stdio: ['ignore', 'pipe', 'pipe'],
70
+ });
71
+ let serverError = '';
72
+ server.stderr.on('data', (data) => { serverError = `${serverError}${data}`.slice(-1_000); });
73
+ server.stdout.resume();
74
+ let socket;
75
+ let native;
76
+ let controller;
77
+ let threadId;
78
+ const events = [];
79
+ const nativeRequests = [];
80
+ const observerRequests = [];
81
+ const observerResponses = [];
82
+ const published = new Map();
83
+ const errors = [];
84
+
85
+ try {
86
+ await until(async () => {
87
+ if (server.exitCode !== null) throw new Error(`Codex exited: ${serverError}`);
88
+ return fetch(`http://127.0.0.1:${port}/readyz`).then((result) => result.ok).catch(() => false);
89
+ }, 'shared app-server readiness');
90
+ socket = new WebSocket(endpoint);
91
+ await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
92
+ let sequence = 0;
93
+ const pending = new Map();
94
+ socket.on('message', (data) => {
95
+ const message = JSON.parse(data.toString());
96
+ if (message.method) {
97
+ if ('id' in message) nativeRequests.push(message);
98
+ else events.push(message);
99
+ return;
100
+ }
101
+ const waiter = pending.get(message.id);
102
+ if (!waiter) { events.push(message); return; }
103
+ pending.delete(message.id);
104
+ clearTimeout(waiter.timer);
105
+ if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
106
+ else waiter.resolve(message.result);
107
+ });
108
+ native = (method, params = {}) => new Promise((resolve, reject) => {
109
+ const id = ++sequence;
110
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 10_000);
111
+ pending.set(id, { resolve, reject, timer });
112
+ socket.send(JSON.stringify({ id, method, params }));
113
+ });
114
+ await native('initialize', { clientInfo: { name: 'canon_attachment_smoke', version: '0.0.0' }, capabilities: { experimentalApi: true } });
115
+ socket.send(JSON.stringify({ method: 'initialized' }));
116
+ const started = await native('thread/start', {
117
+ cwd: directory, ephemeral: false, model: 'probe', modelProvider: 'canon_smoke',
118
+ approvalPolicy: 'on-request', sandbox: 'read-only',
119
+ config: {
120
+ mcp_servers: {}, 'features.apps': false, 'features.plugins': false,
121
+ 'model_providers.canon_smoke': {
122
+ name: 'Canon local mock provider', base_url: `http://127.0.0.1:${modelPort}/v1`,
123
+ wire_api: 'responses', requires_openai_auth: false,
124
+ },
125
+ },
126
+ });
127
+ threadId = started.thread.id;
128
+ async function nativeTurn(text) {
129
+ const result = await native('turn/start', { threadId, input: [{ type: 'text', text }] });
130
+ await until(() => events.some((event) => event.method === 'turn/completed' && event.params.turn.id === result.turn.id), 'native turn completion');
131
+ return result.turn.id;
132
+ }
133
+ await nativeTurn('Private text from before attachment.');
134
+ const binding = { environmentId: 'smoke', agentId: 'smoke-agent', conversationId: 'smoke-room', provider: 'codex', nativeSessionId: threadId };
135
+ const publisher = createCanonAttachedSessionPublisher({
136
+ async sendProactiveMessage(conversationId, text, options) {
137
+ assert.equal(conversationId, binding.conversationId);
138
+ const payload = JSON.stringify({ text, options });
139
+ if (published.has(options.messageId)) assert.equal(published.get(options.messageId), payload);
140
+ published.set(options.messageId, payload);
141
+ return { messageId: options.messageId, conversationId };
142
+ },
143
+ }, binding);
144
+ const attach = async () => {
145
+ const value = createAttachedNativeSession({
146
+ binding, adapter: new CodexAttachedSessionAdapter({ endpoint, threadId, createSocket(url) {
147
+ const observer = new WebSocket(url);
148
+ observer.on('message', (data) => {
149
+ const message = JSON.parse(data.toString());
150
+ if (message.method && 'id' in message) observerRequests.push(message);
151
+ });
152
+ const send = observer.send.bind(observer);
153
+ observer.send = (data, ...args) => {
154
+ const message = JSON.parse(data.toString());
155
+ if ('id' in message && !message.method) observerResponses.push(message);
156
+ return send(data, ...args);
157
+ };
158
+ return observer;
159
+ } }),
160
+ store: createFileAttachedSessionStore(join(directory, 'attachment.json')), publisher,
161
+ onError: (error, operation) => errors.push(`${operation}: ${error.message}`),
162
+ });
163
+ await value.start();
164
+ return value;
165
+ };
166
+ controller = await attach();
167
+ assert.equal(published.size, 0, 'Private history must not be imported');
168
+ await nativeTurn('New input on the native surface.');
169
+ await controller.reconcile();
170
+ assert.equal(published.size, 2, 'Local user and assistant messages should both publish');
171
+ const accepted = await controller.submit({ messageId: 'canon-input-1', text: 'Input from the Canon surface.' });
172
+ assert.equal(accepted.status, 'accepted');
173
+ await until(() => events.some((event) => event.method === 'turn/completed' && event.params.turn.id === accepted.turnId), 'Canon-triggered native completion');
174
+ await controller.reconcile();
175
+ assert.equal(published.size, 3, 'The Canon input echo must be suppressed');
176
+ const sameInput = await controller.submit({ messageId: 'canon-input-1', text: 'Input from the Canon surface.' });
177
+ assert.equal(sameInput.replayed, true, 'Duplicate Canon delivery must not execute twice');
178
+ assert.equal(providerCalls, 3);
179
+ const observed = await native('thread/read', { threadId, includeTurns: true });
180
+ assert.ok(JSON.stringify(observed.thread.turns).includes('Input from the Canon surface.'), 'Native client must see Canon input in its own transcript');
181
+ await controller.stop();
182
+ await nativeTurn('Private work after a graceful detach.');
183
+ controller = await attach();
184
+ assert.equal(published.size, 3, 'Graceful detachment must exclude later private work');
185
+ await nativeTurn('New shared input after reattaching.');
186
+ await controller.reconcile();
187
+ assert.equal(published.size, 5);
188
+ requestApprovalOnNextModelCall = true;
189
+ const approvalTurn = await controller.submit({ messageId: 'canon-approval-input', text: 'Exercise native approval routing with the disposable mock command.' });
190
+ assert.equal(approvalTurn.status, 'accepted');
191
+ const approvalForTurn = (request) => request.method === 'item/commandExecution/requestApproval' && request.params.turnId === approvalTurn.turnId;
192
+ await until(() => nativeRequests.some(approvalForTurn) && observerRequests.some(approvalForTurn), 'approval delivered to both native and attached clients');
193
+ const nativeApproval = nativeRequests.find(approvalForTurn);
194
+ assert.equal(nativeApproval.id, observerRequests.find(approvalForTurn).id, 'Both clients should see the same pending approval');
195
+ assert.equal(observerResponses.length, 0, 'The Canon observer must not answer native approval requests');
196
+ assert.ok(!events.some((event) => event.method === 'turn/completed' && event.params.turn.id === approvalTurn.turnId), 'The turn must wait for the native approval decision');
197
+ // Decline from the native surface. The mock command is never executed.
198
+ socket.send(JSON.stringify({ id: nativeApproval.id, result: { decision: 'decline' } }));
199
+ await until(() => events.some((event) => event.method === 'turn/completed' && event.params.turn.id === approvalTurn.turnId), 'completion after native approval decline');
200
+ assert.ok(events.some((event) => event.method === 'serverRequest/resolved' && event.params.requestId === nativeApproval.id), 'Native decision should resolve the shared approval');
201
+ assert.ok(events.some((event) => event.method === 'item/completed' && event.params.item.id === nativeApproval.params.itemId && event.params.item.status === 'declined'), 'The requested command must be declined without execution');
202
+ await controller.reconcile();
203
+ assert.equal(published.size, 6, 'Only the final assistant text should mirror from the Canon approval turn');
204
+ assert.equal(observerResponses.length, 0);
205
+ assert.equal(errors.length, 0, errors.join('\n'));
206
+ await controller.stop();
207
+ const alive = await native('thread/read', { threadId });
208
+ assert.equal(alive.thread.id, threadId, 'Detachment must leave the shared runner available');
209
+ console.log(JSON.stringify({ passed: true, nativeAndCanonInput: true, privateHistoryExcluded: true, echoSuppressed: true, replayDeduplicated: true, nativeApprovalRouting: true, detachKeepsNativeAlive: true, publishedMessages: published.size, localMockModelCalls: providerCalls }));
210
+ } finally {
211
+ await controller?.stop().catch(() => {});
212
+ if (threadId && socket?.readyState === WebSocket.OPEN) {
213
+ await native('thread/delete', { threadId }).catch((error) => console.error(`Could not delete disposable thread ${threadId}: ${error.message}`));
214
+ }
215
+ socket?.terminate();
216
+ server.kill('SIGTERM');
217
+ await sleep(500);
218
+ if (server.exitCode === null) server.kill('SIGKILL');
219
+ provider.closeAllConnections();
220
+ await new Promise((resolve) => provider.close(resolve));
221
+ await rm(directory, { recursive: true, force: true });
222
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // Real shared Codex protocol with a localhost fake model and Canon publisher.
3
+ // No real model/Canon calls; no HOME/CODEX_HOME or existing-session changes.
4
+ import assert from 'node:assert/strict';
5
+ import { spawn } from 'node:child_process';
6
+ import { mkdtemp, rm } from 'node:fs/promises';
7
+ import { createServer as httpServer } from 'node:http';
8
+ import { createServer as netServer } from 'node:net';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import WebSocket from 'ws';
12
+ import { createAttachedNativeSession, createFileAttachedSessionStore } from '@canonmsg/core';
13
+ import { createCanonAttachedSessionPublisher } from '@canonmsg/agent-sdk';
14
+ import { CodexWorkSessionProvider } from '../dist/work-session-provider.js';
15
+
16
+ const directory = await mkdtemp(join(tmpdir(), 'canon-work-session-smoke-'));
17
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
+ async function until(predicate, label) {
19
+ for (let attempt = 0; attempt < 300; attempt += 1) {
20
+ if (await predicate()) return;
21
+ await sleep(50);
22
+ }
23
+ throw new Error(`Timed out: ${label}`);
24
+ }
25
+ async function listen(server) {
26
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
27
+ return server.address().port;
28
+ }
29
+
30
+ let modelCalls = 0;
31
+ let catalogCalls = 0;
32
+ let askApproval = false;
33
+ const model = httpServer((request, response) => {
34
+ request.resume();
35
+ if (request.method === 'GET' && request.url?.startsWith('/v1/models')) {
36
+ catalogCalls += 1;
37
+ // Exercise the native server's built-in model catalog fallback.
38
+ response.writeHead(404, { 'content-type': 'application/json' });
39
+ response.end(JSON.stringify({ error: 'This disposable provider does not implement model discovery.' }));
40
+ return;
41
+ }
42
+ assert.equal(request.method, 'POST');
43
+ assert.equal(request.url, '/v1/responses');
44
+ modelCalls += 1;
45
+ const item = askApproval ? {
46
+ id: `function_${modelCalls}`, type: 'function_call', call_id: `approval_${modelCalls}`, name: 'exec_command',
47
+ arguments: JSON.stringify({ cmd: 'printf canon-work-session-smoke', sandbox_permissions: 'require_escalated', justification: 'Disposable test; the command will be declined.' }),
48
+ } : { id: `message_${modelCalls}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: `Mock answer ${modelCalls}.`, annotations: [] }] };
49
+ askApproval = false;
50
+ const result = { id: `response_${modelCalls}`, object: 'response', model: 'probe', status: 'completed', output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } };
51
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
52
+ for (const event of [
53
+ { type: 'response.created', response: { ...result, status: 'in_progress', output: [] } },
54
+ { type: 'response.output_item.added', output_index: 0, item },
55
+ { type: 'response.output_item.done', output_index: 0, item },
56
+ { type: 'response.completed', response: result },
57
+ ]) response.write(`data: ${JSON.stringify(event)}\n\n`);
58
+ response.end();
59
+ });
60
+ const modelPort = await listen(model);
61
+ const reservation = netServer();
62
+ const port = await listen(reservation);
63
+ await new Promise((resolve) => reservation.close(resolve));
64
+ const endpoint = `ws://127.0.0.1:${port}`;
65
+ const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _originatorOverride, ...serverEnv } = process.env;
66
+ const server = spawn(process.argv[2] ?? 'codex', [
67
+ 'app-server', '--disable', 'plugins', '--disable', 'apps', '--listen', endpoint,
68
+ '-c', 'model_provider="canon_smoke"',
69
+ '-c', `model_providers.canon_smoke={name="Canon local mock provider",base_url="http://127.0.0.1:${modelPort}/v1",wire_api="responses",requires_openai_auth=false}`,
70
+ ], { cwd: directory, env: serverEnv, stdio: ['ignore', 'pipe', 'pipe'] });
71
+ server.stdout.resume();
72
+ let serverError = '';
73
+ server.stderr.on('data', (data) => { serverError = `${serverError}${data}`.slice(-1_000); });
74
+
75
+ let provider;
76
+ let controller;
77
+ let native;
78
+ let threadId;
79
+ const bridgeRequests = [];
80
+ const nativeResponses = [];
81
+ const published = new Map();
82
+ const errors = [];
83
+ const bridge = { request(request, { signal }) {
84
+ return new Promise((resolve) => { bridgeRequests.push({ request, signal, resolve }); });
85
+ } };
86
+ function makeProvider() {
87
+ return new CodexWorkSessionProvider({ endpoint, cwd: directory, defaultExecutionMode: 'locked',
88
+ createSocket(url) {
89
+ const socket = new WebSocket(url);
90
+ const send = socket.send.bind(socket);
91
+ socket.send = (data, ...args) => {
92
+ const message = JSON.parse(data.toString());
93
+ if ('id' in message && !message.method) nativeResponses.push(message);
94
+ return send(data, ...args);
95
+ };
96
+ return socket;
97
+ },
98
+ onError: (error) => errors.push(error.message),
99
+ });
100
+ }
101
+ async function connectNative() {
102
+ const socket = new WebSocket(endpoint);
103
+ await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
104
+ let sequence = 0;
105
+ const pending = new Map();
106
+ const events = [];
107
+ const requests = [];
108
+ socket.on('message', (data) => {
109
+ const message = JSON.parse(data.toString());
110
+ if (message.method) { ('id' in message ? requests : events).push(message); return; }
111
+ const waiter = pending.get(message.id);
112
+ if (!waiter) return;
113
+ pending.delete(message.id);
114
+ clearTimeout(waiter.timer);
115
+ message.error ? waiter.reject(new Error(JSON.stringify(message.error))) : waiter.resolve(message.result);
116
+ });
117
+ const rpc = (method, params = {}) => new Promise((resolve, reject) => {
118
+ const id = ++sequence;
119
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 10_000);
120
+ pending.set(id, { resolve, reject, timer });
121
+ socket.send(JSON.stringify({ id, method, params }));
122
+ });
123
+ await rpc('initialize', { clientInfo: { name: 'canon_work_session_smoke', version: '0.0.0' }, capabilities: { experimentalApi: true } });
124
+ socket.send(JSON.stringify({ method: 'initialized' }));
125
+ return { socket, rpc, events, requests };
126
+ }
127
+
128
+ try {
129
+ await until(async () => {
130
+ if (server.exitCode !== null) throw new Error(serverError);
131
+ return fetch(`http://127.0.0.1:${port}/readyz`).then((result) => result.ok).catch(() => false);
132
+ }, 'native server readiness');
133
+ provider = makeProvider();
134
+ const catalog = await provider.catalog();
135
+ assert.ok(catalog.models.length > 0);
136
+ assert.ok(!JSON.stringify(catalog).includes(directory), 'Private catalog must not expose raw cwd');
137
+ const created = await provider.create({ mode: 'create', projectId: catalog.projects[0].id,
138
+ modelId: catalog.defaults.modelId, executionMode: 'locked', permissionMode: 'workspace' },
139
+ { requestId: 'create-smoke', conversationId: 'room', agentId: 'agent' });
140
+ threadId = created.nativeSessionId;
141
+ const binding = { environmentId: 'smoke', agentId: 'agent', conversationId: 'room', provider: 'codex', nativeSessionId: threadId };
142
+ const publisher = createCanonAttachedSessionPublisher({ async sendProactiveMessage(conversationId, text, options) {
143
+ const payload = JSON.stringify({ text, options });
144
+ if (published.has(options.messageId)) assert.equal(published.get(options.messageId), payload);
145
+ published.set(options.messageId, payload);
146
+ return { messageId: options.messageId, conversationId };
147
+ } }, binding);
148
+ async function attach() {
149
+ const value = createAttachedNativeSession({ binding, adapter: await provider.open(threadId, bridge),
150
+ store: createFileAttachedSessionStore(join(directory, 'journal.json')), publisher,
151
+ onError: (error, operation) => errors.push(`${operation}: ${error.message}`),
152
+ });
153
+ await value.start();
154
+ return value;
155
+ }
156
+ controller = await attach();
157
+ assert.equal(modelCalls, 0, 'Creating/attaching an empty session must not inject a warmup turn');
158
+ assert.equal(published.size, 0);
159
+ askApproval = true;
160
+ const first = await controller.submit({ messageId: 'canon-first', text: 'First real input from Canon.' });
161
+ assert.equal(first.status, 'accepted');
162
+ await until(() => bridgeRequests.length === 1, 'Canon receives approval without any native client');
163
+ assert.equal(bridgeRequests[0].request.kind, 'approval');
164
+ bridgeRequests[0].resolve({ kind: 'approval', decision: 'deny' });
165
+ await until(async () => { await controller.reconcile(); return controller.getState().activeTurnId === null && published.size === 1; }, 'first Canon turn completion');
166
+ assert.equal(nativeResponses.length, 1);
167
+ assert.deepEqual(nativeResponses[0].result, { decision: 'decline' });
168
+
169
+ native = await connectNative();
170
+ const resumed = await native.rpc('thread/resume', { threadId, excludeTurns: true });
171
+ assert.equal(resumed.thread.id, threadId, 'Native client resumes the exact created session');
172
+ const local = await native.rpc('turn/start', { threadId, input: [{ type: 'text', text: 'Input from the native surface.' }] });
173
+ await until(() => native.events.some((event) => event.method === 'turn/completed' && event.params.turn.id === local.turn.id), 'native turn');
174
+ await controller.reconcile();
175
+ assert.equal(published.size, 3, 'Native user and assistant text should mirror once');
176
+
177
+ askApproval = true;
178
+ const second = await controller.submit({ messageId: 'canon-second', text: 'Native client will answer this approval.' });
179
+ await until(() => bridgeRequests.length === 2 && native.requests.some((request) => request.params.turnId === second.turnId), 'shared approval delivery');
180
+ const approval = native.requests.find((request) => request.params.turnId === second.turnId);
181
+ native.socket.send(JSON.stringify({ id: approval.id, result: { decision: 'decline' } }));
182
+ await until(() => bridgeRequests[1].signal.aborted, 'native answer cancels Canon interaction');
183
+ bridgeRequests[1].resolve({ kind: 'approval', decision: 'allow' });
184
+ await until(async () => { await controller.reconcile(); return controller.getState().activeTurnId === null && published.size === 4; }, 'native approval resolution');
185
+ assert.equal(nativeResponses.length, 1, 'A late Canon answer must not override the native decision');
186
+
187
+ askApproval = true;
188
+ await controller.submit({ messageId: 'canon-third', text: 'Recover this pending approval after the Canon host restarts.' });
189
+ await until(() => bridgeRequests.length === 3, 'approval before host restart');
190
+ await controller.stop();
191
+ await provider.close();
192
+ assert.equal(bridgeRequests[2].signal.aborted, true);
193
+ provider = makeProvider();
194
+ controller = await attach();
195
+ await until(() => bridgeRequests.length === 4, 'pending native approval replay after reconnect');
196
+ assert.equal(bridgeRequests[3].request.nativeRequestId, bridgeRequests[2].request.nativeRequestId);
197
+ bridgeRequests[3].resolve({ kind: 'approval', decision: 'deny' });
198
+ await until(async () => { await controller.reconcile(); return controller.getState().activeTurnId === null && published.size === 5; }, 'recovered approval turn');
199
+ assert.equal(errors.length, 0, errors.join('\n'));
200
+ await controller.stop();
201
+ await provider.close();
202
+ assert.equal((await native.rpc('thread/read', { threadId, includeTurns: false })).thread.id, threadId);
203
+ console.log(JSON.stringify({ passed: true, emptySessionWithoutWarmup: true, canonApprovalWithoutNativeUi: true, nativeAndCanonInput: true, nativeAnswerCancelsCanon: true, pendingApprovalRecovery: true, publishedMessages: published.size, localMockModelCalls: modelCalls, localCatalogCalls: catalogCalls }));
204
+ } finally {
205
+ await controller?.stop().catch(() => {});
206
+ await provider?.close().catch(() => {});
207
+ if (threadId && server.exitCode === null) {
208
+ native ??= await connectNative().catch(() => null);
209
+ if (native) await native.rpc('thread/delete', { threadId }).catch((error) => console.error(`Could not delete disposable native thread: ${error.message}`));
210
+ }
211
+ native?.socket.terminate();
212
+ server.kill('SIGTERM');
213
+ await sleep(400);
214
+ if (server.exitCode === null) server.kill('SIGKILL');
215
+ model.closeAllConnections();
216
+ await new Promise((resolve) => model.close(resolve));
217
+ await rm(directory, { recursive: true, force: true });
218
+ }