@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.45.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/account-limit.d.ts +11 -0
  3. package/dist/account-limit.d.ts.map +1 -0
  4. package/dist/account-limit.js +24 -0
  5. package/dist/account-limit.js.map +1 -0
  6. package/dist/agent-provider.d.ts +62 -0
  7. package/dist/agent-provider.d.ts.map +1 -0
  8. package/dist/agent-provider.js +209 -0
  9. package/dist/agent-provider.js.map +1 -0
  10. package/dist/codex-session.d.ts +220 -0
  11. package/dist/codex-session.d.ts.map +1 -0
  12. package/dist/codex-session.js +1113 -0
  13. package/dist/codex-session.js.map +1 -0
  14. package/dist/constants.d.ts +15 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/constants.js +15 -0
  17. package/dist/constants.js.map +1 -0
  18. package/dist/executable.d.ts +16 -0
  19. package/dist/executable.d.ts.map +1 -0
  20. package/dist/executable.js +43 -0
  21. package/dist/executable.js.map +1 -0
  22. package/dist/index.d.ts +9 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +79 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/login.d.ts +37 -0
  27. package/dist/login.d.ts.map +1 -0
  28. package/dist/login.js +127 -0
  29. package/dist/login.js.map +1 -0
  30. package/dist/offered-models.d.ts +12 -0
  31. package/dist/offered-models.d.ts.map +1 -0
  32. package/dist/offered-models.js +53 -0
  33. package/dist/offered-models.js.map +1 -0
  34. package/dist/own-settings.d.ts +9 -0
  35. package/dist/own-settings.d.ts.map +1 -0
  36. package/dist/own-settings.js +34 -0
  37. package/dist/own-settings.js.map +1 -0
  38. package/dist/protocol.d.ts +279 -0
  39. package/dist/protocol.d.ts.map +1 -0
  40. package/dist/protocol.js +30 -0
  41. package/dist/protocol.js.map +1 -0
  42. package/dist/subagents.d.ts +20 -0
  43. package/dist/subagents.d.ts.map +1 -0
  44. package/dist/subagents.js +90 -0
  45. package/dist/subagents.js.map +1 -0
  46. package/dist/translate.d.ts +87 -0
  47. package/dist/translate.d.ts.map +1 -0
  48. package/dist/translate.js +334 -0
  49. package/dist/translate.js.map +1 -0
  50. package/dist/view-image.d.ts +4 -0
  51. package/dist/view-image.d.ts.map +1 -0
  52. package/dist/view-image.js +47 -0
  53. package/dist/view-image.js.map +1 -0
  54. package/package.json +82 -3
  55. package/src/account-limit.ts +25 -0
  56. package/src/agent-cli.test.ts +76 -0
  57. package/src/agent-provider.test.ts +1556 -0
  58. package/src/agent-provider.ts +234 -0
  59. package/src/codex-session.ts +1258 -0
  60. package/src/constants.ts +18 -0
  61. package/src/event-coverage.test.ts +200 -0
  62. package/src/executable.test.ts +53 -0
  63. package/src/executable.ts +49 -0
  64. package/src/fake-codex.testkit.ts +576 -0
  65. package/src/fixtures/codex-app-server-0.155.1.json +122 -0
  66. package/src/fixtures/codex-app-server-0.163.0.json +125 -0
  67. package/src/fixtures/codex-model-list-0.163.0.json +436 -0
  68. package/src/index.test.ts +45 -0
  69. package/src/index.ts +99 -0
  70. package/src/login.test.ts +93 -0
  71. package/src/login.ts +167 -0
  72. package/src/offered-models.test.ts +87 -0
  73. package/src/offered-models.ts +65 -0
  74. package/src/own-settings.test.ts +45 -0
  75. package/src/own-settings.ts +39 -0
  76. package/src/protocol.ts +256 -0
  77. package/src/subagents.test.ts +47 -0
  78. package/src/subagents.ts +98 -0
  79. package/src/translate.test.ts +160 -0
  80. package/src/translate.ts +362 -0
  81. package/src/view-image.test.ts +29 -0
  82. package/src/view-image.ts +47 -0
  83. package/README.md +0 -3
@@ -0,0 +1,1556 @@
1
+ /**
2
+ * The Codex agent connection, end to end: a real Moxxy session and loop drive
3
+ * `agentStep`, and Codex itself is the stand-in at the `codex app-server`
4
+ * boundary (fake-codex.testkit.ts). Nothing between them is replaced.
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import { z } from 'zod/v3';
8
+ import {
9
+ AGENT_CONNECTION_PLUGIN_ID,
10
+ AGENT_GOAL_SUBTYPE,
11
+ QUESTION_ASKED,
12
+ QUESTION_CLOSED_AGENT_WENT_ON,
13
+ QUESTION_CLOSED_NEW_MESSAGE,
14
+ QUESTION_RESOLVED,
15
+ autoApproveFromEvents,
16
+ definePlugin,
17
+ defineProvider,
18
+ defineTool,
19
+ type MoxxyEvent,
20
+ type PermissionResolver,
21
+ type QuestionItem,
22
+ type Skill,
23
+ type ToolDef,
24
+ } from '@moxxy/sdk';
25
+ import { buildSynthesizeSkillPlugin, collectTurn, type Session } from '@moxxy/core';
26
+ import { FakeProvider, addFakeProvider, createFakeSession, textReply } from '@moxxy/testing';
27
+ import { defaultModePlugin } from '@moxxy/mode-default';
28
+ import { planModePlugin } from '@moxxy/mode-plan';
29
+ import { goalModePlugin } from '@moxxy/mode-goal';
30
+ import { AgentSessionPool } from '@moxxy/agent-connection-kit';
31
+ import { CODEX_AGENT_STARTER_MODEL, CodexAgentProvider, codexAgentModels } from './agent-provider.js';
32
+ import { toolsLentToCodex, type CodexAgentSession } from './codex-session.js';
33
+ import { FakeCodex, type CodexScene, type FakeCodexDefaults } from './fake-codex.testkit.js';
34
+
35
+ const sessions: Session[] = [];
36
+ const pools: AgentSessionPool<CodexAgentSession>[] = [];
37
+ afterEach(async () => {
38
+ await Promise.all(pools.splice(0).map((pool) => pool.closeAll()));
39
+ await Promise.all(sessions.splice(0).map((session) => session.close()));
40
+ });
41
+
42
+ function connect(scene: CodexScene, defaults: FakeCodexDefaults = {}) {
43
+ const codex = new FakeCodex(scene, defaults);
44
+ const pool = new AgentSessionPool<CodexAgentSession>({ limit: 4 });
45
+ pools.push(pool);
46
+ const provider = new CodexAgentProvider({ startAppServer: codex.start, sessions: pool });
47
+ const session = createFakeSession({ provider, cwd: '/work/app' });
48
+ sessions.push(session);
49
+ session.pluginHost.registerStatic(defaultModePlugin);
50
+ return { codex, session, provider, pool };
51
+ }
52
+
53
+ const said = (events: ReadonlyArray<MoxxyEvent>) =>
54
+ events.flatMap((e) => (e.type === 'assistant_message' && e.source === 'model' ? [e.content] : []));
55
+ const autoApproveOf = (session: Session) => autoApproveFromEvents(session.log.ofType('plugin_event'));
56
+ const denyAll: PermissionResolver = { name: 'deny-all', check: async () => ({ mode: 'deny', reason: 'the person said no' }) };
57
+ const allowAll: PermissionResolver = { name: 'allow-all', check: async () => ({ mode: 'allow' }) };
58
+ const ASKING = { approvalPolicy: 'on-request', sandbox: { type: 'readOnly', networkAccess: false } } as const;
59
+ /**
60
+ * Moxxy's asking on a Codex set up never to ask: Codex holds each command and edit for the person,
61
+ * whatever the model decides. The sandbox lets the workspace be edited, so the model, told only
62
+ * what it may read, does not decline a change before Codex can ask.
63
+ */
64
+ const MOXXY_ASKING = {
65
+ approvalPolicy: 'untrusted',
66
+ sandbox: { type: 'workspaceWrite', writableRoots: [], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false },
67
+ } as const;
68
+
69
+ describe('a turn on the Codex agent connection', () => {
70
+ it("starts Codex in the conversation folder with only Moxxy's MCP server and Codex's checklist added, and shows what Codex did", async () => {
71
+ const { codex, session } = connect(async (turn) => {
72
+ await turn.run('echo spike-ok', 'spike-ok\n');
73
+ turn.usage(1200, 40, 1000);
74
+ turn.say(`You said: ${turn.text}`);
75
+ });
76
+
77
+ const events = await collectTurn(session, 'Run echo spike-ok');
78
+
79
+ const server = codex.last;
80
+ expect(server.sent('initialize')[0]).toMatchObject({ clientInfo: { name: 'moxxy' }, capabilities: { experimentalApi: true } });
81
+ expect(server.notifications).toContain('thread/started');
82
+ const [start] = server.sent('thread/start');
83
+ // Codex runs as the person set it up: no instructions, policy or sandbox of Moxxy's, on the
84
+ // model the person picked in Moxxy. Its checklist tool, off by default since Codex 0.152, is on
85
+ // so the step list shows.
86
+ expect(Object.keys(start ?? {}).sort()).toEqual(['config', 'cwd', 'model']);
87
+ expect(start).toMatchObject({ cwd: '/work/app', model: CODEX_AGENT_STARTER_MODEL, config: { mcp_servers: { moxxy: { url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/) } }, 'tools.update_plan.enabled': true } });
88
+ expect(server.sent('turn/start')[0]).toMatchObject({ threadId: 'thread-1', input: [{ type: 'text', text: 'Run echo spike-ok', text_elements: [] }] });
89
+ expect(events).toContainEqual(expect.objectContaining({ type: 'tool_call_requested', name: 'Bash', input: { command: 'echo spike-ok', cwd: '/work/app' }, executedBy: 'provider' }));
90
+ expect(events).toContainEqual(expect.objectContaining({ type: 'tool_result', ok: true, output: 'spike-ok\n' }));
91
+ expect(said(events)).toEqual(['You said: Run echo spike-ok']);
92
+ expect(events).toContainEqual(expect.objectContaining({ type: 'provider_response', inputTokens: 200, outputTokens: 40, cacheReadTokens: 1000 }));
93
+ expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'agent_session', payload: expect.objectContaining({ sessionId: 'thread-1' }) }));
94
+ });
95
+
96
+ it("leaves Codex's checklist as the person set it in their Codex config", async () => {
97
+ const { codex, session } = connect(async (turn) => turn.say('ok'), { planTool: false });
98
+
99
+ await collectTurn(session, 'hi');
100
+
101
+ const [start] = codex.last.sent('thread/start');
102
+ expect(start).toMatchObject({ config: { mcp_servers: { moxxy: expect.anything() } } });
103
+ expect(start ? Object.keys(start.config as object) : []).toEqual(['mcp_servers']);
104
+ });
105
+
106
+ it("records Codex's checklist as a step list each time Codex changes it", async () => {
107
+ const { session } = connect(async (turn) => {
108
+ turn.server.notify('turn/plan/updated', { threadId: turn.threadId, turnId: turn.id, explanation: null, plan: [{ step: 'Create a.txt', status: 'inProgress' }] });
109
+ turn.server.notify('turn/plan/updated', { threadId: turn.threadId, turnId: turn.id, explanation: null, plan: [{ step: 'Create a.txt', status: 'completed' }] });
110
+ turn.say('done');
111
+ });
112
+
113
+ const events = await collectTurn(session, 'make a.txt');
114
+
115
+ const lists = events.flatMap((e) => (e.type === 'tool_result' && e.ok ? [(e.output as { display?: { steps?: unknown } }).display?.steps] : []));
116
+ expect(lists).toEqual([[{ text: 'Create a.txt', status: 'in_progress' }], [{ text: 'Create a.txt', status: 'completed' }]]);
117
+ expect(events.filter((e) => e.type === 'tool_call_requested').map((e) => e.type === 'tool_call_requested' && e.name)).toEqual(['update_plan', 'update_plan']);
118
+ });
119
+
120
+ it("keeps the turn going while a subagent's own thread starts and finishes a turn", async () => {
121
+ const { session } = connect(async (turn) => {
122
+ const child = { threadId: 'thread-child', turn: { id: 'turn-child', items: [], status: 'inProgress', error: null } };
123
+ turn.server.notify('turn/started', child);
124
+ turn.server.notify('turn/completed', { ...child, turn: { ...child.turn, status: 'completed' } });
125
+ await turn.run('echo after', 'after\n');
126
+ turn.say('The subagent found two files.');
127
+ });
128
+
129
+ const events = await collectTurn(session, 'ask a subagent');
130
+
131
+ expect(said(events)).toEqual(['The subagent found two files.']);
132
+ });
133
+
134
+ it("shows a subagent Codex starts as a subagent, with the tools it runs in its own thread and its answer", async () => {
135
+ const { session } = connect(async (turn) => {
136
+ const activity = (kind: string) => ({ threadId: turn.threadId, turnId: turn.id, item: { type: 'subAgentActivity', id: `act-${kind}`, kind, agentThreadId: 'thread-child', agentPath: '/root/inspect_folder' } });
137
+ const child = { threadId: 'thread-child', turnId: 'turn-child' };
138
+ turn.server.notify('item/started', activity('started'));
139
+ turn.server.notify('item/completed', { ...child, item: { type: 'commandExecution', id: 'exec-c', command: 'ls', cwd: '/work/app', status: 'completed', aggregatedOutput: 'a.txt', exitCode: 0 } });
140
+ turn.server.notify('item/completed', { ...child, item: { type: 'agentMessage', id: 'msg-c', text: 'One file: a.txt.', phase: 'final_answer' } });
141
+ turn.server.notify('item/started', activity('completed'));
142
+ turn.say('The subagent found a.txt.');
143
+ });
144
+
145
+ const events = await collectTurn(session, 'ask a subagent');
146
+
147
+ const subagents = events.flatMap((e) => (e.type === 'plugin_event' && e.pluginId === '@moxxy/subagents' ? [[e.subtype, e.payload]] : []));
148
+ expect(subagents).toEqual([
149
+ ['subagent_started', expect.objectContaining({ childSessionId: 'thread-child', label: 'inspect folder' })],
150
+ ['subagent_tool_call', expect.objectContaining({ childSessionId: 'thread-child', name: 'Bash', input: { command: 'ls', cwd: '/work/app' } })],
151
+ ['subagent_completed', expect.objectContaining({ childSessionId: 'thread-child', text: 'One file: a.txt.' })],
152
+ ]);
153
+ expect(events.filter((e) => e.type === 'tool_call_requested')).toEqual([]);
154
+ expect(said(events)).toEqual(['The subagent found a.txt.']);
155
+ });
156
+
157
+ it('opens the thread on the model the person picked, so Codex never switches models before the first turn', async () => {
158
+ // A thread opened on the config's model and switched before the turn carries Codex's
159
+ // model-switch instructions in every later request.
160
+ const { codex, session } = connect(async (turn) => turn.say(turn.thread.model), { model: 'gpt-6.1-sol' });
161
+
162
+ const events = await collectTurn(session, 'which model?');
163
+
164
+ expect(codex.last.sent('thread/start')[0]).toMatchObject({ model: CODEX_AGENT_STARTER_MODEL });
165
+ expect(codex.last.sent('thread/settings/update').some((update) => 'model' in update)).toBe(false);
166
+ expect(said(events)).toEqual([CODEX_AGENT_STARTER_MODEL]);
167
+ });
168
+
169
+ it('switches the thread to another model the person picks later in the conversation', async () => {
170
+ const { codex, session } = connect(async (turn) => turn.say(turn.thread.model));
171
+ const other = codexAgentModels[1]?.id;
172
+
173
+ await collectTurn(session, 'first');
174
+ const events = await collectTurn(session, 'second', { model: other });
175
+
176
+ expect(codex.last.sent('thread/settings/update').filter((update) => 'model' in update)).toEqual([expect.objectContaining({ model: other })]);
177
+ expect(said(events)).toEqual([other]);
178
+ });
179
+
180
+ it('runs Codex on its fast tier while fast mode is on, and on its own tier again when it is off', async () => {
181
+ // Codex names its standard tier `default`.
182
+ const { codex, session } = connect(async (turn) => turn.say(turn.thread.serviceTier ?? 'default'), { serviceTier: 'default' });
183
+
184
+ session.fast = true;
185
+ const fast = await collectTurn(session, 'quick');
186
+ session.fast = false;
187
+ const normal = await collectTurn(session, 'slow');
188
+
189
+ const updates = codex.last.sent('thread/settings/update');
190
+ expect(updates[0]).toMatchObject({ serviceTier: 'priority' });
191
+ expect(updates[1]).toMatchObject({ serviceTier: null });
192
+ expect(updates).toHaveLength(2);
193
+ expect([...said(fast), ...said(normal)]).toEqual(['priority', 'default']);
194
+ });
195
+
196
+ it('leaves a Codex set up for its fast tier on it', async () => {
197
+ const { codex, session } = connect(async (turn) => turn.say(turn.thread.serviceTier ?? 'standard'), { serviceTier: 'priority' });
198
+
199
+ const events = await collectTurn(session, 'quick');
200
+
201
+ expect(codex.last.sent('thread/settings/update')[0]).not.toHaveProperty('serviceTier');
202
+ expect(said(events)).toEqual(['priority']);
203
+ });
204
+
205
+ it('keeps the same thread for the next message of the conversation', async () => {
206
+ const { codex, session } = connect(async (turn) => turn.say(turn.text === 'first' ? 'one' : 'two'));
207
+
208
+ await collectTurn(session, 'first');
209
+ const second = await collectTurn(session, 'second');
210
+
211
+ expect(codex.servers).toHaveLength(1);
212
+ expect(codex.last.sent('thread/start')).toHaveLength(1);
213
+ expect(codex.last.sent('turn/start')).toHaveLength(2);
214
+ expect(said(second)).toEqual(['two']);
215
+ });
216
+
217
+ it('resumes the thread in a new Codex when the old one was closed and the conversation goes on', async () => {
218
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'));
219
+ await collectTurn(session, 'remember MARMOT-55');
220
+ await pool.close(String(session.id));
221
+
222
+ await collectTurn(session, 'which word?');
223
+
224
+ expect(codex.servers).toHaveLength(2);
225
+ expect(codex.servers[0]?.closed).toBe(true);
226
+ expect(codex.last.sent('thread/resume')[0]).toMatchObject({ threadId: 'thread-1', cwd: '/work/app', model: CODEX_AGENT_STARTER_MODEL });
227
+ expect(codex.last.sent('thread/start')).toHaveLength(0);
228
+ });
229
+
230
+ it('starts a new thread when Codex no longer has the old one', async () => {
231
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'));
232
+ await collectTurn(session, 'hello');
233
+ await pool.close(String(session.id));
234
+ codex.saved.clear();
235
+
236
+ const events = await collectTurn(session, 'hello again');
237
+
238
+ expect(codex.last.sent('thread/start')).toHaveLength(1);
239
+ expect(events).toContainEqual(expect.objectContaining({ subtype: 'agent_session', payload: expect.objectContaining({ sessionId: 'thread-2' }) }));
240
+ });
241
+
242
+ it('hears, when the person comes back to it, what the conversation did on another connection meanwhile', async () => {
243
+ const { codex, session, provider } = connect(async (turn) => turn.say('ok'));
244
+ addFakeProvider(session, new FakeProvider({ name: 'stand-in', script: [textReply('Renamed it to orders.')] }));
245
+
246
+ await collectTurn(session, 'use port 5433');
247
+ session.providers.setActive('stand-in');
248
+ await collectTurn(session, 'rename the table to orders');
249
+ session.providers.setActive(provider.name);
250
+ await collectTurn(session, 'what did you rename?');
251
+ await collectTurn(session, 'thanks');
252
+
253
+ expect(codex.last.sent('thread/start')).toHaveLength(1);
254
+ const [, back, after] = codex.last.sent('turn/start');
255
+ const [note, prompt] = (back?.input ?? []) as Array<{ text: string }>;
256
+ expect(note?.text).toMatch(/another connection/);
257
+ expect(note?.text).toContain('### Turn 2\nPerson: rename the table to orders\nAgent: Renamed it to orders.');
258
+ expect(note?.text).not.toContain('use port 5433');
259
+ expect(prompt?.text).toBe('what did you rename?');
260
+ expect(after?.input).toEqual([{ type: 'text', text: 'thanks', text_elements: [] }]);
261
+ });
262
+
263
+ it('hands a new thread the conversation so far when the earlier one is gone, once', async () => {
264
+ const { codex, session, pool } = connect(async (turn) => turn.say('port 5433 it is'));
265
+ await collectTurn(session, 'use port 5433');
266
+ await pool.close(String(session.id));
267
+ codex.saved.clear();
268
+
269
+ await collectTurn(session, 'which port?');
270
+ await collectTurn(session, 'thanks');
271
+
272
+ const [first, second] = codex.last.sent('turn/start');
273
+ const [note, prompt] = (first?.input ?? []) as Array<{ text: string }>;
274
+ expect(note?.text).toMatch(/^Your earlier session for this Moxxy conversation no longer exists/);
275
+ expect(note?.text).toContain('### Turn 1\nPerson: use port 5433\nAgent: port 5433 it is');
276
+ expect(note?.text).not.toContain('which port?');
277
+ expect(prompt?.text).toBe('which port?');
278
+ expect(second?.input).toEqual([{ type: 'text', text: 'thanks', text_elements: [] }]);
279
+ });
280
+
281
+ it('says nothing more than the message to a new conversation', async () => {
282
+ const { codex, session } = connect(async (turn) => turn.say('hi'));
283
+
284
+ await collectTurn(session, 'hello');
285
+
286
+ expect(codex.last.sent('turn/start')[0]?.input).toEqual([{ type: 'text', text: 'hello', text_elements: [] }]);
287
+ });
288
+
289
+ it('resumes the thread when Codex fails to read it once, instead of starting another', async () => {
290
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'), { resumeFailures: 1 });
291
+ await collectTurn(session, 'remember MARMOT-55');
292
+ await pool.close(String(session.id));
293
+
294
+ await collectTurn(session, 'which word?');
295
+
296
+ expect(codex.last.sent('thread/resume')).toHaveLength(2);
297
+ expect(codex.last.sent('thread/start')).toHaveLength(0);
298
+ });
299
+
300
+ it('starts no other thread when Codex keeps failing to read it, and says what happened', async () => {
301
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'), { resumeFailures: 2 });
302
+ await collectTurn(session, 'remember MARMOT-55');
303
+ await pool.close(String(session.id));
304
+
305
+ const events = await collectTurn(session, 'which word?');
306
+
307
+ expect(codex.last.sent('thread/start')).toHaveLength(0);
308
+ expect(events).toContainEqual(
309
+ expect.objectContaining({ type: 'error', message: expect.stringContaining("Codex could not load this conversation's earlier session (failed to read the thread store)") }),
310
+ );
311
+ });
312
+
313
+ it('hands an image of the person to Codex as an image', async () => {
314
+ const { codex, session } = connect(async (turn) => turn.say(`${turn.input.length} parts`));
315
+
316
+ await collectTurn(session, 'what colour?', {
317
+ attachments: [{ kind: 'image', mediaType: 'image/png', content: 'iVBORw0KGgo=', name: 'shot.png' }],
318
+ });
319
+
320
+ expect(codex.last.sent('turn/start')[0]?.input).toEqual([
321
+ { type: 'text', text: 'what colour?', text_elements: [] },
322
+ { type: 'image', url: 'data:image/png;base64,iVBORw0KGgo=' },
323
+ ]);
324
+ });
325
+ });
326
+
327
+ describe('approvals: one state with Codex own policy', () => {
328
+ it('shows auto-approve on when Codex runs with no approvals and full access', async () => {
329
+ const { session } = connect(async (turn) => turn.say('ok'));
330
+
331
+ await collectTurn(session, 'hi');
332
+
333
+ expect(autoApproveOf(session)).toBe(true);
334
+ });
335
+
336
+ it("keeps auto-approve off the person chose before the first message, over Codex's own no-approvals policy", async () => {
337
+ let decision: unknown;
338
+ const { session } = connect(async (turn) => {
339
+ decision = await turn.run('touch spike.txt', '', { approval: true });
340
+ turn.say('done');
341
+ });
342
+ session.setPermissionResolver(denyAll);
343
+ await session.setAutoApprove(true);
344
+ await session.setAutoApprove(false);
345
+
346
+ await collectTurn(session, 'create spike.txt');
347
+
348
+ expect(autoApproveOf(session)).toBe(false);
349
+ expect(decision).toEqual({ decision: 'decline' });
350
+ });
351
+
352
+ it('asks the person before a command Codex wants approved, and tells Codex the answer', async () => {
353
+ let decision: unknown;
354
+ const { session } = connect(
355
+ async (turn) => {
356
+ decision = await turn.run('touch spike.txt', '', { approval: true, reason: 'May I create spike.txt?' });
357
+ turn.say('I could not create it.');
358
+ },
359
+ ASKING,
360
+ );
361
+ session.setPermissionResolver(denyAll);
362
+
363
+ const events = await collectTurn(session, 'create spike.txt');
364
+
365
+ expect(autoApproveOf(session)).toBe(false);
366
+ expect(decision).toEqual({ decision: 'decline' });
367
+ const call = events.find((e) => e.type === 'tool_call_requested' && e.name === 'Bash');
368
+ if (call?.type !== 'tool_call_requested') throw new Error('expected the command to be shown');
369
+ const order = events.filter((e) => 'callId' in e && e.callId === call.callId).map((e) => e.type);
370
+ expect(order).toEqual(['tool_call_requested', 'tool_call_denied', 'tool_result']);
371
+ });
372
+
373
+ it("has the person answer, not Codex's own reviewer, while auto-approve is off", async () => {
374
+ let decision: unknown;
375
+ const { codex, session } = connect(
376
+ async (turn) => {
377
+ decision = await turn.run('touch spike.txt', '', { approval: true });
378
+ turn.say('done');
379
+ },
380
+ { ...ASKING, approvalsReviewer: 'auto_review' },
381
+ );
382
+ session.setPermissionResolver(denyAll);
383
+
384
+ await collectTurn(session, 'create spike.txt');
385
+
386
+ expect(codex.last.sent('thread/settings/update')[0]).toMatchObject({ approvalsReviewer: 'user' });
387
+ expect(decision).toEqual({ decision: 'decline' });
388
+ });
389
+
390
+ it("leaves Codex's own reviewer where Moxxy answers nothing itself", async () => {
391
+ const { codex, session } = connect(async (turn) => turn.say('ok'), { approvalsReviewer: 'auto_review' });
392
+
393
+ await collectTurn(session, 'hi');
394
+
395
+ expect(autoApproveOf(session)).toBe(true);
396
+ expect(codex.last.sent('thread/settings/update').some((update) => 'approvalsReviewer' in update)).toBe(false);
397
+ });
398
+
399
+ it("tells Codex in its running turn why the person declined, since Codex's answer carries no reason", async () => {
400
+ const { codex, session } = connect(
401
+ async (turn) => {
402
+ await turn.run('touch app.js', '', { approval: true });
403
+ turn.say(`Understood: ${await turn.steered()}`);
404
+ },
405
+ ASKING,
406
+ );
407
+ session.setPermissionResolver({ name: 'person', check: async () => ({ mode: 'deny', reason: 'not now, build it in goal mode' }) });
408
+
409
+ const events = await collectTurn(session, 'build it');
410
+
411
+ expect(codex.last.sent('turn/steer')[0]).toMatchObject({
412
+ threadId: 'thread-1',
413
+ expectedTurnId: 'turn-1',
414
+ input: [{ type: 'text', text: expect.stringContaining('not now, build it in goal mode') }],
415
+ });
416
+ expect(said(events)[0]).toContain('Bash');
417
+ expect(said(events)[0]).toContain('not now, build it in goal mode');
418
+ });
419
+
420
+ it('carries the reason into the next message when the turn can no longer take it', async () => {
421
+ const { codex, session } = connect(
422
+ async (turn) => {
423
+ if (turn.text === 'build it') await turn.run('touch app.js', '', { approval: true });
424
+ turn.say('ok');
425
+ },
426
+ { ...ASKING, refuseSteer: true },
427
+ );
428
+ session.setPermissionResolver({ name: 'person', check: async () => ({ mode: 'deny', reason: 'not now' }) });
429
+
430
+ await collectTurn(session, 'build it');
431
+ await collectTurn(session, 'what next?');
432
+ await collectTurn(session, 'and then?');
433
+
434
+ const [, next, after] = codex.last.sent('turn/start');
435
+ expect(next?.input).toEqual([
436
+ { type: 'text', text: expect.stringContaining('not now'), text_elements: [] },
437
+ { type: 'text', text: 'what next?', text_elements: [] },
438
+ ]);
439
+ expect(after?.input).toEqual([{ type: 'text', text: 'and then?', text_elements: [] }]);
440
+ });
441
+
442
+ it('lets Codex go ahead when the person allows', async () => {
443
+ let decision: unknown;
444
+ const { session } = connect(
445
+ async (turn) => {
446
+ decision = await turn.run('touch spike.txt', '', { approval: true });
447
+ turn.say('Created.');
448
+ },
449
+ ASKING,
450
+ );
451
+ session.setPermissionResolver(allowAll);
452
+
453
+ await collectTurn(session, 'create spike.txt');
454
+
455
+ expect(decision).toEqual({ decision: 'accept' });
456
+ });
457
+
458
+ it('turning auto-approve off makes Codex ask, for this conversation only; on again restores how Codex was set up', async () => {
459
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
460
+ await collectTurn(session, 'one');
461
+
462
+ await session.setAutoApprove(false);
463
+ await collectTurn(session, 'two');
464
+ expect(codex.last.thread).toMatchObject(MOXXY_ASKING);
465
+
466
+ await session.setAutoApprove(true);
467
+ await collectTurn(session, 'three');
468
+ expect(codex.last.thread).toMatchObject({ approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' } });
469
+ });
470
+
471
+ it('lets the model ask for each write on a Codex that no longer takes untrusted, rather than fail the turn', async () => {
472
+ const { codex, session } = connect(async (turn) => turn.say('ok'), { refusedApprovalPolicies: ['untrusted'] });
473
+ await collectTurn(session, 'one');
474
+ await session.setAutoApprove(false);
475
+
476
+ const events = await collectTurn(session, 'two');
477
+ await collectTurn(session, 'three');
478
+
479
+ expect(said(events)).toEqual(['ok']);
480
+ expect(codex.last.thread).toMatchObject(ASKING);
481
+ });
482
+
483
+ it('shows auto-approve off when the thread starts asking without Moxxy changing it, and leaves Codex so', async () => {
484
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
485
+ await collectTurn(session, 'one');
486
+ expect(autoApproveOf(session)).toBe(true);
487
+
488
+ codex.last.changeSettingsElsewhere(ASKING);
489
+ await collectTurn(session, 'two');
490
+
491
+ expect(autoApproveOf(session)).toBe(false);
492
+ expect(codex.last.thread).toMatchObject(ASKING);
493
+ });
494
+
495
+ it('follows a change of the thread approvals in the middle of a turn, and says it came from outside Moxxy', async () => {
496
+ const { codex, session } = connect(async (turn) => {
497
+ if (turn.text === 'two') turn.server.changeSettingsElsewhere(ASKING);
498
+ turn.say('ok');
499
+ });
500
+ await collectTurn(session, 'one');
501
+
502
+ const events = await collectTurn(session, 'two');
503
+
504
+ expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'auto_approve', payload: { enabled: false, elsewhere: true } }));
505
+ expect(autoApproveOf(session)).toBe(false);
506
+ expect(codex.last.thread).toMatchObject(ASKING);
507
+ });
508
+
509
+ it('keeps a tighter way of asking set outside Moxxy rather than loosen it back on the next turn', async () => {
510
+ const UNTRUSTED = { approvalPolicy: 'untrusted', sandbox: { type: 'readOnly', networkAccess: false } } as const;
511
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
512
+ await collectTurn(session, 'one');
513
+
514
+ codex.last.changeSettingsElsewhere(UNTRUSTED);
515
+ await collectTurn(session, 'two');
516
+ await collectTurn(session, 'three');
517
+
518
+ expect(autoApproveOf(session)).toBe(false);
519
+ expect(codex.last.thread).toMatchObject(UNTRUSTED);
520
+ });
521
+
522
+ it('sets the thread back to asking when something outside Moxxy lets Codex run without asking between turns', async () => {
523
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
524
+ await collectTurn(session, 'one');
525
+ await session.setAutoApprove(false);
526
+ await collectTurn(session, 'two');
527
+
528
+ codex.last.changeSettingsElsewhere({ approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' } });
529
+ const events = await collectTurn(session, 'three');
530
+
531
+ expect(autoApproveOf(session)).toBe(false);
532
+ expect(codex.last.thread).toMatchObject(MOXXY_ASKING);
533
+ expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'auto_approve_restored' }));
534
+ });
535
+
536
+ it('sets the thread back to asking at once when it is loosened outside Moxxy in the middle of a turn', async () => {
537
+ const { codex, session } = connect(async (turn) => {
538
+ if (turn.text === 'three') turn.server.changeSettingsElsewhere({ approvalPolicy: 'never', sandbox: { type: 'workspaceWrite', writableRoots: [], networkAccess: false } });
539
+ turn.say('ok');
540
+ });
541
+ await collectTurn(session, 'one');
542
+ await session.setAutoApprove(false);
543
+ await collectTurn(session, 'two');
544
+
545
+ const events = await collectTurn(session, 'three');
546
+
547
+ expect(autoApproveOf(session)).toBe(false);
548
+ expect(codex.last.thread).toMatchObject(MOXXY_ASKING);
549
+ expect(events.filter((e) => e.type === 'plugin_event' && e.subtype === 'auto_approve_restored')).toHaveLength(1);
550
+ });
551
+
552
+ it("gives the person's answers back when Codex's own reviewer is put in their place outside Moxxy", async () => {
553
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
554
+ await collectTurn(session, 'one');
555
+ await session.setAutoApprove(false);
556
+ await collectTurn(session, 'two');
557
+
558
+ codex.last.changeSettingsElsewhere({ approvalsReviewer: 'auto_review' });
559
+ await collectTurn(session, 'three');
560
+
561
+ expect(codex.last.thread).toMatchObject({ ...MOXXY_ASKING, approvalsReviewer: 'user' });
562
+ });
563
+
564
+ it('keeps plan mode read-only when something outside Moxxy gives Codex full access during it', async () => {
565
+ const { codex, session } = connect(async (turn) => {
566
+ turn.server.changeSettingsElsewhere({ approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' } });
567
+ turn.say('plan');
568
+ });
569
+ session.pluginHost.registerStatic(planModePlugin);
570
+ session.modes.setActive('plan');
571
+
572
+ await collectTurn(session, 'plan it');
573
+
574
+ expect(codex.last.thread).toMatchObject({ approvalPolicy: 'never', sandbox: { type: 'readOnly' } });
575
+ });
576
+
577
+ it("does not take Moxxy's own changes, nor plan mode's read-only, for a change of auto-approve", async () => {
578
+ const { session } = connect(async (turn) => turn.say('ok'));
579
+ session.pluginHost.registerStatic(planModePlugin);
580
+ await collectTurn(session, 'one');
581
+ await session.setAutoApprove(false);
582
+ await collectTurn(session, 'two');
583
+ await session.setAutoApprove(true);
584
+ session.modes.setActive('plan');
585
+
586
+ await collectTurn(session, 'plan it');
587
+
588
+ expect(autoApproveOf(session)).toBe(true);
589
+ });
590
+
591
+ it('keeps auto-approve the person turned on when a new Codex resumes the thread', async () => {
592
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'), ASKING);
593
+ await collectTurn(session, 'one');
594
+ await session.setAutoApprove(true);
595
+ await collectTurn(session, 'two');
596
+ await pool.close(String(session.id));
597
+
598
+ await collectTurn(session, 'three');
599
+
600
+ expect(codex.last.sent('thread/resume')).toHaveLength(1);
601
+ expect(autoApproveOf(session)).toBe(true);
602
+ expect(codex.last.thread).toMatchObject({ approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' } });
603
+ });
604
+
605
+ it('keeps auto-approve the person turned off when a new Codex resumes the thread', async () => {
606
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'));
607
+ await collectTurn(session, 'one');
608
+ await session.setAutoApprove(false);
609
+ await collectTurn(session, 'two');
610
+ await pool.close(String(session.id));
611
+
612
+ await collectTurn(session, 'three');
613
+
614
+ expect(codex.last.sent('thread/resume')).toHaveLength(1);
615
+ expect(autoApproveOf(session)).toBe(false);
616
+ expect(codex.last.thread).toMatchObject(MOXXY_ASKING);
617
+ });
618
+
619
+ it("gives Codex its own reviewer back after a new Codex resumed a thread Moxxy had set to the person's answers", async () => {
620
+ const { codex, session, pool } = connect(async (turn) => turn.say('ok'), { approvalsReviewer: 'auto_review' });
621
+ await collectTurn(session, 'one');
622
+ await session.setAutoApprove(false);
623
+ await collectTurn(session, 'two');
624
+ await pool.close(String(session.id));
625
+ await collectTurn(session, 'three');
626
+
627
+ await session.setAutoApprove(true);
628
+ await collectTurn(session, 'four');
629
+
630
+ expect(codex.last.thread).toMatchObject({ approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' }, approvalsReviewer: 'auto_review' });
631
+ });
632
+
633
+ it('lends Moxxy tools over MCP, run through Moxxy permissions and shown once', async () => {
634
+ let ran = 0;
635
+ const browserOpen = defineTool({
636
+ name: 'browser_open',
637
+ description: 'Open a page in the visible browser.',
638
+ inputSchema: z.object({ url: z.string() }),
639
+ handler: async ({ url }) => {
640
+ ran += 1;
641
+ return `opened ${url}`;
642
+ },
643
+ });
644
+ let lent: unknown;
645
+ const { session } = connect(async (turn) => {
646
+ lent = await turn.callMoxxyTool('browser_open', { url: 'http://localhost:8765' });
647
+ turn.completed({ type: 'mcpToolCall', id: 'mcp-1', server: 'moxxy', tool: 'browser_open', status: 'completed', arguments: {}, result: {}, error: null });
648
+ turn.say('Opened.');
649
+ });
650
+ session.pluginHost.registerStatic(definePlugin({ name: 'test-browser', tools: [browserOpen] }));
651
+
652
+ const events = await collectTurn(session, 'open the app');
653
+
654
+ expect(ran).toBe(1);
655
+ expect(lent).toMatchObject({ content: [{ type: 'text', text: 'opened http://localhost:8765' }] });
656
+ expect(events.filter((e) => e.type === 'tool_call_requested').map((e) => (e as { name: string }).name)).toEqual(['browser_open']);
657
+ });
658
+
659
+ it('lists the Moxxy tools as Codex opens or resumes the thread, the only time Codex reads them', async () => {
660
+ const recallLike = defineTool({ name: 'note_lookup', description: 'Look a note up.', inputSchema: z.object({}), handler: async () => 'the note' });
661
+ const found: unknown[] = [];
662
+ const { session, pool } = connect(async (turn) => {
663
+ found.push(await turn.callMoxxyTool('note_lookup', {}));
664
+ turn.say('ok');
665
+ });
666
+ session.pluginHost.registerStatic(definePlugin({ name: 'test-notes', tools: [recallLike] }));
667
+
668
+ await collectTurn(session, 'look it up');
669
+ await pool.close(String(session.id));
670
+ await collectTurn(session, 'again, after a restart');
671
+
672
+ expect(found).toEqual([
673
+ expect.objectContaining({ content: [{ type: 'text', text: 'the note' }] }),
674
+ expect.objectContaining({ content: [{ type: 'text', text: 'the note' }] }),
675
+ ]);
676
+ });
677
+ });
678
+
679
+ describe('the Moxxy tools Codex can see', () => {
680
+ const askLike = defineTool({ name: 'ask_like', description: 'Ask the person.', inputSchema: z.object({}), notInModes: ['goal'], handler: async () => 'answered' });
681
+
682
+ it('lists every Moxxy tool from the start, and says why one is not available in this mode', async () => {
683
+ const results: Array<{ content: unknown; isError?: boolean }> = [];
684
+ const { session } = connect(async (turn) => {
685
+ results.push(await turn.callMoxxyTool('ask_like', {}));
686
+ if (turn.objective !== undefined) turn.goal('complete');
687
+ turn.say('ok');
688
+ });
689
+ session.pluginHost.registerStatic(definePlugin({ name: 'test-ask', tools: [askLike] }));
690
+ session.pluginHost.registerStatic(goalModePlugin);
691
+ session.modes.setActive('goal');
692
+
693
+ await collectTurn(session, 'do it');
694
+ session.modes.setActive('default');
695
+ await collectTurn(session, 'now ask me');
696
+
697
+ expect(results[0]).toEqual({ content: [{ type: 'text', text: expect.stringMatching(/^ask_like is not available in goal mode/) }], isError: true });
698
+ expect(results[1]).toEqual({ content: [{ type: 'text', text: 'answered' }] });
699
+ });
700
+
701
+ it("shows them to the model upfront, not behind Codex's tool search where it never looks for them", async () => {
702
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
703
+
704
+ await collectTurn(session, 'hi');
705
+
706
+ expect(codex.last.sent('thread/start')[0]).toMatchObject({ config: { mcp_servers: { moxxy: { omit_tools_from: ['deferred'] } } } });
707
+ });
708
+
709
+ it('lends them as a catalog of three small tools, so their full descriptions are not in every request, and the chat shows the tool that ran', async () => {
710
+ const noted = defineTool({ name: 'note_lookup', description: 'Look a note up.', inputSchema: z.object({}), handler: async () => 'the note' });
711
+ let read: ReadonlyArray<string> = [];
712
+ const results: unknown[] = [];
713
+ const { session } = connect(async (turn) => {
714
+ read = turn.thread.moxxyTools;
715
+ results.push(await turn.callMoxxyTool('note_lookup', {}));
716
+ turn.say('ok');
717
+ });
718
+ session.pluginHost.registerStatic(definePlugin({ name: 'test-notes', tools: [noted] }));
719
+
720
+ const events = await collectTurn(session, 'look it up');
721
+
722
+ expect(read.filter((name) => name !== 'load_skill')).toEqual(['find_tools', 'describe_tools', 'call_tool']);
723
+ expect(results).toEqual([{ content: [{ type: 'text', text: 'the note' }] }]);
724
+ expect(events).toContainEqual(expect.objectContaining({ type: 'tool_call_requested', name: 'note_lookup' }));
725
+ expect(events.some((e) => e.type === 'tool_call_requested' && e.name === 'call_tool')).toBe(false);
726
+ });
727
+
728
+ it('resumes the thread in a new Codex when the conversation gains a tool, so Codex reads the new list', async () => {
729
+ const noted = defineTool({ name: 'note_lookup', description: 'Look a note up.', inputSchema: z.object({}), handler: async () => 'the note' });
730
+ const found: unknown[] = [];
731
+ const { codex, session } = connect(async (turn) => {
732
+ if (turn.text === 'look it up') found.push(await turn.callMoxxyTool('note_lookup', {}));
733
+ turn.say('ok');
734
+ });
735
+ await collectTurn(session, 'hello');
736
+ const first = codex.last;
737
+ session.pluginHost.registerStatic(definePlugin({ name: 'test-notes', tools: [noted] }));
738
+
739
+ await collectTurn(session, 'look it up');
740
+
741
+ expect(codex.last).not.toBe(first);
742
+ expect(codex.last.sent('thread/resume')[0]).toMatchObject({ threadId: 'thread-1' });
743
+ expect(found).toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'the note' }] })]);
744
+ });
745
+ });
746
+
747
+ describe("the person's Moxxy skills on Codex", () => {
748
+ const skill = (name: string): Skill => ({
749
+ id: name as Skill['id'],
750
+ path: `/skills/${name}.md`,
751
+ scope: 'project',
752
+ frontmatter: { name, description: `About ${name}` },
753
+ body: `Steps for ${name}.`,
754
+ });
755
+
756
+ function withSkills(scene: CodexScene, defaults: FakeCodexDefaults = {}) {
757
+ const connected = connect(scene, defaults);
758
+ connected.session.pluginHost.registerStatic(buildSynthesizeSkillPlugin(connected.session));
759
+ connected.session.setPermissionResolver(allowAll);
760
+ return connected;
761
+ }
762
+
763
+ it("lets Codex read one, leaving a skill Codex has itself to Codex's", async () => {
764
+ const read: Array<{ content: unknown; isError?: boolean }> = [];
765
+ const { session } = withSkills(
766
+ async (turn) => {
767
+ read.push(await turn.callMoxxyTool('load_skill', { name: 'monthly-invoices' }));
768
+ read.push(await turn.callMoxxyTool('load_skill', { name: 'deploy-staging' }));
769
+ turn.say('ok');
770
+ },
771
+ { ownSkills: ['deploy-staging'] },
772
+ );
773
+ session.skills.register(skill('monthly-invoices'));
774
+ session.skills.register(skill('deploy-staging'));
775
+
776
+ const events = await collectTurn(session, 'do the invoices');
777
+
778
+ expect(read[0]).toEqual({ content: [{ type: 'text', text: expect.stringContaining('Steps for monthly-invoices.') }] });
779
+ expect(read[1]).toMatchObject({ isError: true, content: [{ text: expect.stringContaining('No Moxxy skill is named "deploy-staging"') }] });
780
+ expect(events).toContainEqual(expect.objectContaining({ type: 'skill_invoked', name: 'monthly-invoices' }));
781
+ });
782
+
783
+ it('resumes the thread in a new Codex when a skill is saved, so Codex reads it at the next turn', async () => {
784
+ const read: Array<{ content: unknown; isError?: boolean }> = [];
785
+ const { codex, session } = withSkills(async (turn) => {
786
+ if (turn.text === 'use it') read.push(await turn.callMoxxyTool('load_skill', { name: 'monthly-invoices' }));
787
+ turn.say('ok');
788
+ });
789
+ await collectTurn(session, 'hello');
790
+ const first = codex.last;
791
+ session.skills.register(skill('monthly-invoices'));
792
+
793
+ await collectTurn(session, 'use it');
794
+
795
+ expect(codex.last).not.toBe(first);
796
+ expect(codex.last.sent('thread/resume')[0]).toMatchObject({ threadId: 'thread-1' });
797
+ expect(read).toEqual([{ content: [{ type: 'text', text: expect.stringContaining('Steps for monthly-invoices.') }] }]);
798
+ });
799
+ });
800
+
801
+ describe('reasoning summaries', () => {
802
+ it("asks for Codex's summaries of its thinking when the person's config leaves them to the model", async () => {
803
+ const { codex, session } = connect(async (turn) => turn.say('ok'));
804
+
805
+ await collectTurn(session, 'hi');
806
+
807
+ expect(codex.last.sent('config/read')[0]).toMatchObject({ cwd: '/work/app' });
808
+ expect(codex.last.sent('thread/settings/update')[0]).toMatchObject({ summary: 'auto' });
809
+ });
810
+
811
+ it('keeps the summaries the person set in their Codex config', async () => {
812
+ const { codex, session } = connect(async (turn) => turn.say('ok'), { reasoningSummary: 'none' });
813
+
814
+ await collectTurn(session, 'hi');
815
+ await collectTurn(session, 'again');
816
+
817
+ expect(codex.last.sent('thread/settings/update').some((update) => 'summary' in update)).toBe(false);
818
+ });
819
+ });
820
+
821
+ describe('plan mode', () => {
822
+ it('plans in Codex own plan mode, read-only, and shows its plan in the Moxxy plan card', async () => {
823
+ const plan = '# Fix add\n\n## Summary\n\nUse a + b.\n';
824
+ const { codex, session } = connect(async (turn) => {
825
+ if (turn.thread.collaborationMode === 'plan') turn.completed({ type: 'plan', id: `${turn.id}-plan`, text: plan });
826
+ else turn.say('Implemented.');
827
+ });
828
+ session.pluginHost.registerStatic(planModePlugin);
829
+ session.modes.setActive('plan');
830
+
831
+ const events = await collectTurn(session, 'plan the fix');
832
+
833
+ expect(codex.last.sent('thread/settings/update')[0]).toMatchObject({
834
+ sandboxPolicy: { type: 'readOnly', networkAccess: false },
835
+ collaborationMode: { mode: 'plan', settings: { model: CODEX_AGENT_STARTER_MODEL, developer_instructions: null } },
836
+ });
837
+ expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'plan_completed', payload: { title: 'Fix add', questions: 0 } }));
838
+ expect(events.filter((e) => e.type === 'assistant_message').at(-1)).toMatchObject({ source: 'system', content: plan });
839
+
840
+ session.modes.setActive('default');
841
+ const next = await collectTurn(session, 'Implement the approved plan above.');
842
+ expect(codex.last.thread).toMatchObject({ collaborationMode: 'default', approvalPolicy: 'never', sandbox: { type: 'dangerFullAccess' } });
843
+ expect(said(next)).toEqual(['Implemented.']);
844
+ });
845
+
846
+ it('shows Codex questions in the Moxxy question card and sends the answers as the next message', async () => {
847
+ const { codex, session } = connect(async (turn) => {
848
+ if (turn.text === 'plan the button') {
849
+ turn.say('Which colour should the button be?\n- Red\n- Blue', {
850
+ delivery: 'async',
851
+ questions: [{ title: 'Which colour should the button be?', options: ['Red', 'Blue'] }, { title: 'What label?', options: null }],
852
+ });
853
+ return;
854
+ }
855
+ turn.completed({ type: 'plan', id: `${turn.id}-plan`, text: `# Button\n\n${turn.text}` });
856
+ });
857
+ session.pluginHost.registerStatic(planModePlugin);
858
+ session.modes.setActive('plan');
859
+ session.questions.attachSurface();
860
+ let asked: ReadonlyArray<QuestionItem> = [];
861
+ session.log.subscribe((event) => {
862
+ if (event.type !== 'plugin_event' || event.subtype !== QUESTION_ASKED) return;
863
+ const { questionId, questions } = event.payload as { questionId: string; questions: ReadonlyArray<QuestionItem> };
864
+ asked = questions;
865
+ const red = questions[0]?.options.find((o) => o.label === 'Red');
866
+ void session.questions.answer(questionId, {
867
+ [questions[0]?.id ?? '']: { selected: red ? [red.id] : [] },
868
+ [questions[1]?.id ?? '']: { selected: [], other: 'Draw one' },
869
+ });
870
+ });
871
+
872
+ const events = await collectTurn(session, 'plan the button');
873
+
874
+ expect(asked.map((q) => [q.question, q.options.map((o) => o.label), q.allowOther])).toEqual([
875
+ ['Which colour should the button be?', ['Red', 'Blue'], true],
876
+ ['What label?', [], true],
877
+ ]);
878
+ expect(codex.last.sent('turn/start')[1]?.input).toEqual([
879
+ { type: 'text', text: 'Which colour should the button be?: Red\nWhat label?: Draw one', text_elements: [] },
880
+ ]);
881
+ expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'plan_completed' }));
882
+ });
883
+ });
884
+
885
+ describe('a question Codex asks while it works', () => {
886
+ const asksThenWorks = (after: (turn: Parameters<CodexScene>[0]) => Promise<void>): CodexScene => async (turn) => {
887
+ if (turn.text !== 'fix the page') return turn.say('ok');
888
+ turn.say('Should I keep the old header?', { delivery: 'async', questions: [{ title: 'Keep the old header?', options: ['Yes', 'No'] }] });
889
+ await turn.run('ls', 'index.html\n');
890
+ await after(turn);
891
+ };
892
+ const questionRecords = (session: Session) =>
893
+ session.log.ofType('plugin_event').filter((e) => e.subtype === QUESTION_ASKED || e.subtype === QUESTION_RESOLVED);
894
+
895
+ it('is shown at once and does not hold the turn: when Codex finishes without it, it closes as Codex went on', async () => {
896
+ const { codex, session } = connect(asksThenWorks(async (turn) => turn.say('Done, I kept the header.')));
897
+ session.questions.attachSurface();
898
+
899
+ const events = await collectTurn(session, 'fix the page');
900
+
901
+ expect(said(events)).toEqual(['Should I keep the old header?', 'Done, I kept the header.']);
902
+ expect(questionRecords(session).map((e) => [e.subtype, (e.payload as { note?: string }).note])).toEqual([
903
+ [QUESTION_ASKED, undefined],
904
+ [QUESTION_RESOLVED, QUESTION_CLOSED_AGENT_WENT_ON],
905
+ ]);
906
+ expect(codex.last.sent('turn/start')).toHaveLength(1);
907
+ });
908
+
909
+ it('takes an answer given while Codex works into its running turn', async () => {
910
+ const { codex, session } = connect(asksThenWorks(async (turn) => turn.say(`Got: ${await turn.steered()}`)));
911
+ session.questions.attachSurface();
912
+ session.log.subscribe((event) => {
913
+ if (event.type !== 'plugin_event' || event.subtype !== QUESTION_ASKED) return;
914
+ const { questionId, questions } = event.payload as { questionId: string; questions: ReadonlyArray<QuestionItem> };
915
+ const no = questions[0]?.options.find((o) => o.label === 'No');
916
+ void session.questions.answer(questionId, { [questions[0]?.id ?? '']: { selected: no ? [no.id] : [] } });
917
+ });
918
+
919
+ const events = await collectTurn(session, 'fix the page');
920
+
921
+ expect(said(events).at(-1)).toBe('Got: Keep the old header?: No');
922
+ expect(codex.last.sent('turn/start')).toHaveLength(1);
923
+ });
924
+
925
+ it('lets go of a question Codex ended its turn on when the person writes a new message instead', async () => {
926
+ const { session } = connect(async (turn) => {
927
+ if (turn.text === 'plan the button') turn.say('Which colour?', { delivery: 'async', questions: [{ title: 'Which colour?', options: ['Red', 'Blue'] }] });
928
+ else turn.say(`On it: ${turn.text}`);
929
+ });
930
+ session.questions.attachSurface();
931
+ const asked = new Promise<void>((resolve) =>
932
+ session.log.subscribe((event) => {
933
+ if (event.type === 'plugin_event' && event.subtype === QUESTION_ASKED) resolve();
934
+ }),
935
+ );
936
+
937
+ const first = collectTurn(session, 'plan the button');
938
+ await asked;
939
+ const second = await collectTurn(session, 'make it green');
940
+
941
+ expect(said(second)).toEqual(['On it: make it green']);
942
+ await first;
943
+ expect(questionRecords(session).at(-1)).toMatchObject({ subtype: QUESTION_RESOLVED, payload: { note: QUESTION_CLOSED_NEW_MESSAGE } });
944
+ });
945
+
946
+ it('interrupts a blocking user-input request when the person sends a new message instead', async () => {
947
+ const { codex, session } = connect(async (turn) => {
948
+ if (turn.text !== 'plan the colour') return turn.say(`On it: ${turn.text}`);
949
+ await turn.request('item/tool/requestUserInput', {
950
+ threadId: turn.threadId,
951
+ turnId: turn.id,
952
+ itemId: 'colour-question',
953
+ questions: [{ id: 'colour', header: 'Colour', question: 'Which colour?', options: [{ label: 'Green', description: 'Green background' }, { label: 'Blue', description: 'Blue background' }] }],
954
+ });
955
+ // A dismissed question is not an answer: Codex can keep waiting in its turn.
956
+ if (!turn.signal.aborted) await new Promise<void>((resolve) => turn.signal.addEventListener('abort', () => resolve(), { once: true }));
957
+ });
958
+ session.questions.attachSurface();
959
+ const asked = new Promise<void>((resolve) => {
960
+ session.log.subscribe((event) => {
961
+ if (event.type === 'plugin_event' && event.subtype === QUESTION_ASKED) resolve();
962
+ });
963
+ });
964
+
965
+ const first = collectTurn(session, 'plan the colour');
966
+ await asked;
967
+ const second = collectTurn(session, 'use green');
968
+ await vi.waitFor(() => expect(codex.last.sent('turn/interrupt')).toHaveLength(1));
969
+
970
+ expect(said(await second)).toEqual(['On it: use green']);
971
+ await first;
972
+ expect(questionRecords(session).at(-1)).toMatchObject({ subtype: QUESTION_RESOLVED, payload: { note: QUESTION_CLOSED_NEW_MESSAGE } });
973
+ });
974
+
975
+ it('closes when the person stops the turn', async () => {
976
+ const stop = new AbortController();
977
+ const { session } = connect(asksThenWorks((turn) => new Promise((resolve) => turn.signal.addEventListener('abort', resolve, { once: true }))));
978
+ session.questions.attachSurface();
979
+ session.log.subscribe((event) => {
980
+ if (event.type === 'plugin_event' && event.subtype === QUESTION_ASKED) stop.abort();
981
+ });
982
+
983
+ await collectTurn(session, 'fix the page', { signal: stop.signal });
984
+
985
+ expect(questionRecords(session).at(-1)).toMatchObject({ subtype: QUESTION_RESOLVED, payload: { dismissed: 'cancelled' } });
986
+ });
987
+ });
988
+
989
+ describe('goal mode', () => {
990
+ it('runs a goal with Codex own goal, through its own turns, and ends Moxxy goal mode when Codex says it is met', async () => {
991
+ let turns = 0;
992
+ const { codex, session } = connect(async (turn) => {
993
+ turns += 1;
994
+ if (turns === 1) {
995
+ turn.say('Writing goal.txt.');
996
+ return;
997
+ }
998
+ await turn.run('cat goal.txt', 'GOAL-DONE\n');
999
+ turn.goal('complete');
1000
+ turn.say('goal.txt says GOAL-DONE.');
1001
+ });
1002
+ session.pluginHost.registerStatic(goalModePlugin);
1003
+ session.modes.setActive('goal');
1004
+
1005
+ const events = await collectTurn(session, 'goal.txt says GOAL-DONE');
1006
+
1007
+ const server = codex.last;
1008
+ expect(server.sent('thread/goal/set')[0]).toEqual({ threadId: 'thread-1', objective: 'goal.txt says GOAL-DONE' });
1009
+ expect(server.sent('turn/start')).toHaveLength(0);
1010
+ expect(server.sent('thread/settings/update')[0]).not.toHaveProperty('approvalPolicy');
1011
+ expect(turns).toBe(2);
1012
+ expect(said(events)).toEqual(['Writing goal.txt.', 'goal.txt says GOAL-DONE.']);
1013
+ expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: AGENT_GOAL_SUBTYPE, payload: expect.objectContaining({ status: 'complete' }) }));
1014
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(true);
1015
+ expect(session.modes.getActiveName()).toBe('default');
1016
+ });
1017
+
1018
+ it('hands a new thread the conversation so far in the first turn of its goal', async () => {
1019
+ let handed = '';
1020
+ const { codex, session, pool } = connect(async (turn) => {
1021
+ if (turn.objective !== undefined) {
1022
+ handed = await turn.steered();
1023
+ turn.goal('complete');
1024
+ }
1025
+ turn.say('ok');
1026
+ });
1027
+ await collectTurn(session, 'use port 5433');
1028
+ await pool.close(String(session.id));
1029
+ codex.saved.clear();
1030
+ session.pluginHost.registerStatic(goalModePlugin);
1031
+ session.modes.setActive('goal');
1032
+
1033
+ await collectTurn(session, 'deploy on that port');
1034
+
1035
+ expect(handed).toMatch(/^Your earlier session for this Moxxy conversation no longer exists[\s\S]*Person: use port 5433/);
1036
+ expect(codex.last.sent('thread/goal/set')[0]).toMatchObject({ objective: 'deploy on that port' });
1037
+ });
1038
+
1039
+ it('leaves a goal Codex stopped on (blocked) to Codex, without Moxxy pushing it on', async () => {
1040
+ const { codex, session } = connect(async (turn) => {
1041
+ turn.say('I need the API key to go on.');
1042
+ turn.goal('blocked');
1043
+ });
1044
+ session.pluginHost.registerStatic(goalModePlugin);
1045
+ session.modes.setActive('goal');
1046
+
1047
+ await collectTurn(session, 'deploy it');
1048
+
1049
+ // Codex starts the goal's turn itself; Moxxy adds no message of its own.
1050
+ expect(codex.last.sent('turn/start')).toHaveLength(0);
1051
+ expect(codex.turnCount).toBe(1);
1052
+ expect(session.modes.getActiveName()).toBe('goal');
1053
+ });
1054
+
1055
+ it('runs the goal with full access even when the person has Codex ask, without changing the conversation auto-approve', async () => {
1056
+ const { codex, session } = connect(async (turn) => {
1057
+ if (turn.objective !== undefined) turn.goal('complete');
1058
+ turn.say('done');
1059
+ }, ASKING);
1060
+ await collectTurn(session, 'hello');
1061
+ session.pluginHost.registerStatic(goalModePlugin);
1062
+ session.modes.setActive('goal');
1063
+
1064
+ await collectTurn(session, 'finish it');
1065
+
1066
+ expect(codex.last.sent('thread/settings/update').at(-1)).toMatchObject({ approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } });
1067
+ expect(autoApproveOf(session)).toBe(false);
1068
+ });
1069
+
1070
+ it('clears a met goal before setting the next one', async () => {
1071
+ const { codex, session } = connect(async (turn) => {
1072
+ turn.goal('complete');
1073
+ turn.say('done');
1074
+ });
1075
+ session.pluginHost.registerStatic(goalModePlugin);
1076
+ session.modes.setActive('goal');
1077
+ await collectTurn(session, 'first goal');
1078
+ session.modes.setActive('goal');
1079
+
1080
+ const events = await collectTurn(session, 'second goal');
1081
+
1082
+ expect(codex.last.requests.map((r) => r.method).filter((m) => m.startsWith('thread/goal/'))).toEqual([
1083
+ 'thread/goal/set',
1084
+ 'thread/goal/clear',
1085
+ 'thread/goal/set',
1086
+ ]);
1087
+ expect(said(events)).toEqual(['done']);
1088
+ });
1089
+
1090
+ it('interrupts the goal on Stop and keeps it, so the next message carries on with it', async () => {
1091
+ const controller = new AbortController();
1092
+ let turns = 0;
1093
+ const { codex, session } = connect(async (turn) => {
1094
+ turns += 1;
1095
+ if (turns === 1) {
1096
+ turn.say('Counting.');
1097
+ controller.abort();
1098
+ await new Promise(() => undefined);
1099
+ }
1100
+ turn.goal('complete');
1101
+ turn.say(`carried on: ${turn.text}`);
1102
+ });
1103
+ session.pluginHost.registerStatic(goalModePlugin);
1104
+ session.modes.setActive('goal');
1105
+
1106
+ const stopped = await collectTurn(session, 'count to 300', { signal: controller.signal });
1107
+ const next = await collectTurn(session, 'go on');
1108
+
1109
+ expect(codex.last.sent('turn/interrupt')).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
1110
+ expect(stopped.map((e) => e.type)).toContain('abort');
1111
+ expect(codex.last.sent('thread/goal/set')).toHaveLength(1);
1112
+ expect(codex.last.sent('turn/start')[0]?.input).toEqual([{ type: 'text', text: 'go on', text_elements: [] }]);
1113
+ expect(said(next)).toEqual(['carried on: go on']);
1114
+ });
1115
+
1116
+ it('clears an open goal when the conversation leaves goal mode', async () => {
1117
+ const controller = new AbortController();
1118
+ const { codex, session } = connect(async (turn) => {
1119
+ if (turn.objective !== undefined) {
1120
+ controller.abort();
1121
+ await new Promise(() => undefined);
1122
+ }
1123
+ turn.say('plain');
1124
+ });
1125
+ session.pluginHost.registerStatic(goalModePlugin);
1126
+ session.modes.setActive('goal');
1127
+ await collectTurn(session, 'a goal', { signal: controller.signal });
1128
+ session.modes.setActive('default');
1129
+
1130
+ const events = await collectTurn(session, 'just a question');
1131
+
1132
+ expect(codex.last.sent('thread/goal/clear')).toHaveLength(1);
1133
+ expect(said(events)).toEqual(['plain']);
1134
+ });
1135
+ });
1136
+
1137
+ describe('stopping and failing', () => {
1138
+ it('interrupts Codex on Stop, and the next message works as usual', async () => {
1139
+ const controller = new AbortController();
1140
+ const { codex, session } = connect(async (turn) => {
1141
+ if (turn.text === 'wait') {
1142
+ turn.started({ type: 'commandExecution', id: 'exec-1', command: "/bin/zsh -lc 'sleep 100'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null });
1143
+ controller.abort();
1144
+ await new Promise(() => undefined);
1145
+ }
1146
+ turn.say('back');
1147
+ });
1148
+
1149
+ const stopped = await collectTurn(session, 'wait', { signal: controller.signal });
1150
+ const next = await collectTurn(session, 'again');
1151
+
1152
+ expect(codex.last.sent('turn/interrupt')).toHaveLength(1);
1153
+ expect(stopped.map((e) => e.type)).toContain('abort');
1154
+ expect(stopped).toContainEqual(expect.objectContaining({ type: 'tool_result', callId: 'exec-1', ok: false }));
1155
+ expect(said(next)).toEqual(['back']);
1156
+ });
1157
+
1158
+ it('on Stop ends the commands the stopped turn started, and leaves running what an earlier turn started', async () => {
1159
+ const controller = new AbortController();
1160
+ const { codex, session } = connect(async (turn) => {
1161
+ if (turn.text === 'serve') {
1162
+ turn.started({ type: 'commandExecution', id: 'exec-server', command: "/bin/zsh -lc 'npm run dev'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null });
1163
+ turn.say('The dev server runs.');
1164
+ return;
1165
+ }
1166
+ turn.started({ type: 'commandExecution', id: 'exec-wait', command: "/bin/zsh -lc 'sleep 100'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null });
1167
+ controller.abort();
1168
+ await new Promise(() => undefined);
1169
+ });
1170
+
1171
+ await collectTurn(session, 'serve');
1172
+ await collectTurn(session, 'wait', { signal: controller.signal });
1173
+
1174
+ expect(codex.last.sent('thread/backgroundTerminals/terminate')).toEqual([{ threadId: 'thread-1', processId: '2' }]);
1175
+ expect([...codex.last.thread.terminals.keys()]).toEqual(['exec-server']);
1176
+ });
1177
+
1178
+ it('shows the prompt of the last Codex call as how full the context is, not the sum of the turn', async () => {
1179
+ const { session } = connect(async (turn) => {
1180
+ turn.usage(150_000, 40, 148_000);
1181
+ turn.usage(152_000, 60, 150_000);
1182
+ turn.say('Done.');
1183
+ });
1184
+
1185
+ const events = await collectTurn(session, 'fix it');
1186
+
1187
+ expect(events.find((e) => e.type === 'provider_response')).toMatchObject({ contextTokens: 152_000 });
1188
+ });
1189
+
1190
+ it('records the context window Codex runs the model with, for every surface to show', async () => {
1191
+ const { session } = connect(async (turn) => {
1192
+ turn.usage(1200, 40, 0, 380_000);
1193
+ turn.say('Hi.');
1194
+ });
1195
+
1196
+ await collectTurn(session, 'hi');
1197
+
1198
+ const model = session.getInfo().providers.find((p) => p.name === 'codex-agent')?.models.find((m) => m.id === CODEX_AGENT_STARTER_MODEL);
1199
+ expect(model?.contextWindow).toBe(380_000);
1200
+ });
1201
+
1202
+ it('reports a failed Codex turn as an error', async () => {
1203
+ const { session } = connect(async () => ({ status: 'failed', error: 'You have hit your usage limit.' }));
1204
+
1205
+ const events = await collectTurn(session, 'hi');
1206
+
1207
+ expect(events).toContainEqual(expect.objectContaining({ type: 'error', kind: 'fatal', message: expect.stringContaining('usage limit') }));
1208
+ });
1209
+
1210
+ it('ends the turn when Codex stops, and starts Codex again for the next message', async () => {
1211
+ let calls = 0;
1212
+ const { codex, session } = connect(async (turn) => {
1213
+ calls += 1;
1214
+ if (calls === 1) {
1215
+ turn.server.crash();
1216
+ await new Promise(() => undefined);
1217
+ }
1218
+ turn.say('fine now');
1219
+ });
1220
+
1221
+ const failed = await collectTurn(session, 'hi');
1222
+ const next = await collectTurn(session, 'hi again');
1223
+
1224
+ expect(failed).toContainEqual(expect.objectContaining({ type: 'error', kind: 'fatal', message: expect.stringContaining('Codex stopped') }));
1225
+ expect(codex.servers).toHaveLength(2);
1226
+ expect(said(next)).toEqual(['fine now']);
1227
+ });
1228
+ });
1229
+
1230
+ describe("Codex's account limit", () => {
1231
+ /** As Codex 0.155.1 sent it on a Plus plan: one weekly window. `resetsAt` is in epoch seconds. */
1232
+ const rateLimits = (usedPercent: number, extra: Record<string, unknown> = {}) => ({
1233
+ rateLimits: {
1234
+ limitId: 'codex',
1235
+ limitName: null,
1236
+ primary: { usedPercent, windowDurationMins: 10080, resetsAt: 1792310000 },
1237
+ secondary: null,
1238
+ credits: { hasCredits: false, unlimited: false, balance: '0' },
1239
+ planType: 'plus',
1240
+ rateLimitReachedType: null,
1241
+ ...extra,
1242
+ },
1243
+ });
1244
+ const limitsIn = (events: ReadonlyArray<MoxxyEvent>) =>
1245
+ events.flatMap((e) => (e.type === 'plugin_event' && e.subtype === 'account_limit' ? [(e.payload as { limit: unknown }).limit] : []));
1246
+
1247
+ it('reads how much of the window is used and when it resets, near from three quarters', async () => {
1248
+ let used = 10;
1249
+ const { session } = connect(async (turn) => {
1250
+ turn.server.notify('account/rateLimits/updated', rateLimits(used));
1251
+ turn.say('hi');
1252
+ });
1253
+
1254
+ const first = await collectTurn(session, 'hi');
1255
+ used = 80;
1256
+ const second = await collectTurn(session, 'hi');
1257
+
1258
+ expect(limitsIn(first)).toEqual([{ state: 'ok', usedPercent: 10, windowMinutes: 10080, resetsAt: 1792310000_000 }]);
1259
+ expect(limitsIn(second)).toEqual([{ state: 'near', usedPercent: 80, windowMinutes: 10080, resetsAt: 1792310000_000 }]);
1260
+ });
1261
+
1262
+ it('keeps the window it knows when a rolling update leaves it out', async () => {
1263
+ let update = rateLimits(80);
1264
+ const { session } = connect(async (turn) => {
1265
+ turn.server.notify('account/rateLimits/updated', update);
1266
+ turn.say('hi');
1267
+ });
1268
+
1269
+ await collectTurn(session, 'hi');
1270
+ update = rateLimits(0, { primary: null });
1271
+ const second = await collectTurn(session, 'hi');
1272
+
1273
+ expect(limitsIn(second)).toEqual([]);
1274
+ });
1275
+
1276
+ it('ends a turn Codex failed on its usage limit with the limit, not the raw error', async () => {
1277
+ const { session } = connect(async (turn) => {
1278
+ turn.server.notify('account/rateLimits/updated', rateLimits(100, { rateLimitReachedType: 'rate_limit_reached' }));
1279
+ return { status: 'failed', error: "You've hit your usage limit.", errorInfo: 'usageLimitExceeded' };
1280
+ });
1281
+
1282
+ const events = await collectTurn(session, 'hi');
1283
+
1284
+ expect(limitsIn(events)).toEqual([{ state: 'reached', usedPercent: 100, windowMinutes: 10080, resetsAt: 1792310000_000 }]);
1285
+ expect(events).toContainEqual(expect.objectContaining({ subtype: 'account_limit', payload: expect.objectContaining({ provider: 'codex-agent', agent: 'Codex' }) }));
1286
+ expect(events.some((e) => e.type === 'error')).toBe(false);
1287
+ });
1288
+
1289
+ it('knows the limit is reached from the failure alone when Codex sent no update before it', async () => {
1290
+ const { session } = connect(async () => ({ status: 'failed', error: "You've hit your usage limit.", errorInfo: 'usageLimitExceeded' }));
1291
+
1292
+ const events = await collectTurn(session, 'hi');
1293
+
1294
+ expect(limitsIn(events)).toEqual([{ state: 'reached' }]);
1295
+ });
1296
+ });
1297
+
1298
+ describe('one-off text calls', () => {
1299
+ it('asks Codex for plain text on a read-only thread it does not keep', async () => {
1300
+ const codex = new FakeCodex(async (turn) => turn.say(`summary of: ${turn.text.slice(0, 19)}`));
1301
+ const provider = new CodexAgentProvider({ startAppServer: codex.start, cwd: '/work/app' });
1302
+
1303
+ const out: string[] = [];
1304
+ for await (const event of provider.stream({
1305
+ model: 'gpt-6-luna',
1306
+ system: 'Summarize.',
1307
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'A long conversation' }] }],
1308
+ })) {
1309
+ if (event.type === 'text_delta') out.push(event.delta);
1310
+ }
1311
+
1312
+ expect(out.join('')).toBe('summary of: A long conversation');
1313
+ expect(codex.last.sent('thread/start')[0]).toMatchObject({
1314
+ cwd: '/work/app',
1315
+ ephemeral: true,
1316
+ approvalPolicy: 'never',
1317
+ sandbox: 'read-only',
1318
+ model: 'gpt-6-luna',
1319
+ developerInstructions: 'Summarize.',
1320
+ });
1321
+ expect(codex.last.closed).toBe(true);
1322
+ });
1323
+
1324
+ it('gives Codex the instructions of the request as developer instructions, not as words the person said', async () => {
1325
+ const codex = new FakeCodex(async (turn) => turn.say(`asked: ${turn.text}`));
1326
+ const provider = new CodexAgentProvider({ startAppServer: codex.start, cwd: '/work/app' });
1327
+
1328
+ const out: string[] = [];
1329
+ for await (const event of provider.stream({
1330
+ model: 'gpt-6-luna',
1331
+ messages: [
1332
+ { role: 'system', content: [{ type: 'text', text: 'Plan search queries.' }] },
1333
+ { role: 'user', content: [{ type: 'text', text: 'Find the release date.' }] },
1334
+ ],
1335
+ })) {
1336
+ if (event.type === 'text_delta') out.push(event.delta);
1337
+ }
1338
+
1339
+ expect(out.join('')).toBe('asked: Find the release date.');
1340
+ expect(codex.last.sent('thread/start')[0]).toMatchObject({ developerInstructions: 'Plan search queries.' });
1341
+ });
1342
+ });
1343
+
1344
+ describe('an MCP server set up both in Moxxy and in Codex', () => {
1345
+ it("reaches Codex once, through Codex's own setup; one switched off there is still lent", async () => {
1346
+ const mcpTool = (name: string) => defineTool({ name, description: name, inputSchema: z.object({}), handler: async () => 'ok' });
1347
+ const results: Record<string, { isError?: boolean }> = {};
1348
+ const { codex, session } = connect(
1349
+ async (turn) => {
1350
+ for (const name of ['mcp__notes__read', 'mcp__vault__secret_word']) results[name] = await turn.callMoxxyTool(name, {});
1351
+ turn.say('ok');
1352
+ },
1353
+ { mcpServers: { vault: { enabled: true }, notes: { enabled: false } } },
1354
+ );
1355
+ session.pluginHost.registerStatic(definePlugin({ name: 'mcp-servers', tools: [mcpTool('mcp__vault__secret_word'), mcpTool('mcp__notes__read')] }));
1356
+ session.setPermissionResolver(allowAll);
1357
+
1358
+ await collectTurn(session, 'which tools?');
1359
+ await collectTurn(session, 'and now?');
1360
+
1361
+ expect(results['mcp__notes__read']?.isError).toBeFalsy();
1362
+ expect(results['mcp__vault__secret_word']?.isError).toBe(true);
1363
+ expect(codex.last.sent('thread/start')).toHaveLength(1);
1364
+ });
1365
+ });
1366
+
1367
+ describe('the Moxxy tools Codex gets', () => {
1368
+ it('include recall, which searches the whole conversation, and not what Codex has its own of', () => {
1369
+ const tools = ['Read', 'recall', 'session_recall', 'present_view'].map((name) => ({ name }) as ToolDef);
1370
+
1371
+ expect(toolsLentToCodex(tools).map((tool) => tool.name)).toEqual(['recall', 'present_view']);
1372
+ });
1373
+ });
1374
+
1375
+ describe('work Codex leaves running when its turn ends', () => {
1376
+ /** Codex does not start a turn when it finishes (checked with 0.155.1): Moxxy opens one to show the end, sending Codex nothing. */
1377
+ async function leaveRunning(start: (turn: Parameters<CodexScene>[0]) => void) {
1378
+ let later: ((method: string, params: Record<string, unknown>) => void) | undefined;
1379
+ let ids = { threadId: '', turnId: '' };
1380
+ const { codex, session, pool } = connect(async (turn) => {
1381
+ if (turn.text === '2+2?') return turn.say('4');
1382
+ ids = { threadId: turn.threadId, turnId: turn.id };
1383
+ later = (method, params) => turn.server.notify(method, params);
1384
+ start(turn);
1385
+ turn.say('started');
1386
+ });
1387
+ session.setPermissionResolver(allowAll);
1388
+ const first = await collectTurn(session, 'Start it and do not wait');
1389
+ return { codex, session, pool, first, ids, later: (method: string, params: Record<string, unknown>) => later?.(method, params) };
1390
+ }
1391
+
1392
+ const opened = (session: Session) => session.log.ofType('user_prompt').filter((e) => e.origin?.kind === 'agent');
1393
+
1394
+ it('shows a command still running in the background as such, and its end in a turn Moxxy opens for it', async () => {
1395
+ const command = { type: 'commandExecution', id: 'bg-1', command: "/bin/zsh -lc 'sleep 25 && echo BG-OK'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null };
1396
+ const { codex, session, first, ids, later } = await leaveRunning((turn) => turn.started(command));
1397
+
1398
+ expect(first.filter((e) => e.type === 'tool_result')).toEqual([
1399
+ expect.objectContaining({ callId: 'bg-1', ok: true, output: 'Still running in the background when Codex ended its turn.' }),
1400
+ ]);
1401
+
1402
+ later('item/completed', { ...ids, item: { ...command, status: 'completed', aggregatedOutput: 'BG-OK\n', exitCode: 0 } });
1403
+ await vi.waitFor(() => expect(opened(session)).toHaveLength(1));
1404
+ const turn = opened(session)[0];
1405
+ await vi.waitFor(() => expect(session.log.slice().some((e) => e.type === 'provider_response' && turn && e.turnId === turn.turnId)).toBe(true));
1406
+
1407
+ expect(turn).toMatchObject({ origin: { kind: 'agent', name: 'A command Codex left running finished' } });
1408
+ const inIt = session.log.slice().filter((e) => turn && e.turnId === turn.turnId);
1409
+ expect(inIt.filter((e) => e.type === 'tool_call_requested')).toEqual([expect.objectContaining({ name: 'Bash', input: { command: 'sleep 25 && echo BG-OK', cwd: '/work/app' } })]);
1410
+ expect(inIt.filter((e) => e.type === 'tool_result')).toEqual([expect.objectContaining({ ok: true, output: 'BG-OK\n' })]);
1411
+ expect(codex.last.sent('turn/start')).toHaveLength(1);
1412
+
1413
+ expect(said(await collectTurn(session, '2+2?'))).toEqual(['4']);
1414
+ });
1415
+
1416
+ it('opens the turn when the conversation went to another connection and came back while the command ran', async () => {
1417
+ const command = { type: 'commandExecution', id: 'bg-2', command: "/bin/zsh -lc 'sleep 20'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null };
1418
+ const { codex, session, pool, ids, later } = await leaveRunning((turn) => turn.started(command));
1419
+ addFakeProvider(session, new FakeProvider({ name: 'stand-in', script: [] }));
1420
+
1421
+ // The runner switches connections this way: a fresh client each time the conversation comes back.
1422
+ session.providers.setActive('stand-in');
1423
+ session.providers.replace(
1424
+ defineProvider({ name: 'codex-agent', models: [], createClient: () => new CodexAgentProvider({ startAppServer: codex.start, sessions: pool }) }),
1425
+ );
1426
+ session.providers.setActive('codex-agent');
1427
+
1428
+ later('item/completed', { ...ids, item: { ...command, status: 'completed', aggregatedOutput: '', exitCode: 0 } });
1429
+ await vi.waitFor(() => expect(opened(session)).toHaveLength(1));
1430
+ });
1431
+
1432
+ it("shows a subagent's work as it happens after the turn, and its answer in a turn Moxxy opens for it", async () => {
1433
+ const activity = (kind: string) => ({ type: 'subAgentActivity', id: `act-${kind}`, kind, agentThreadId: 'thread-child', agentPath: '/root/check_later' });
1434
+ const child = { threadId: 'thread-child', turnId: 'turn-child' };
1435
+ const { codex, session, ids, later } = await leaveRunning((turn) => turn.server.notify('item/started', { threadId: turn.threadId, turnId: turn.id, item: activity('started') }));
1436
+
1437
+ later('item/completed', { ...child, item: { type: 'commandExecution', id: 'exec-c', command: 'sleep 25', cwd: '/work/app', status: 'completed', aggregatedOutput: '', exitCode: 0 } });
1438
+ later('item/completed', { ...child, item: { type: 'agentMessage', id: 'msg-c', text: 'DONE-7', phase: 'final_answer' } });
1439
+ later('item/started', { ...ids, turnId: 'turn-x', item: activity('completed') });
1440
+ await vi.waitFor(() => expect(opened(session)).toHaveLength(1));
1441
+ const turn = opened(session)[0];
1442
+ await vi.waitFor(() => expect(session.log.slice().some((e) => e.type === 'provider_response' && turn && e.turnId === turn.turnId)).toBe(true));
1443
+
1444
+ expect(turn).toMatchObject({ origin: { kind: 'agent', name: 'check later finished in the background', workId: 'thread-child' } });
1445
+ const before = session.log.slice().filter((e) => turn && e.seq < turn.seq);
1446
+ expect(before.some((e) => e.type === 'plugin_event' && e.subtype === 'subagent_tool_call')).toBe(true);
1447
+ const inIt = session.log.slice().filter((e) => turn && e.turnId === turn.turnId);
1448
+ expect(inIt.flatMap((e) => (e.type === 'plugin_event' ? [[e.subtype, e.payload]] : []))).toEqual(
1449
+ expect.arrayContaining([['subagent_completed', expect.objectContaining({ childSessionId: 'thread-child', text: 'DONE-7' })]]),
1450
+ );
1451
+ expect(codex.last.sent('turn/start')).toHaveLength(1);
1452
+ });
1453
+
1454
+ const workOf = (session: Session, subtype: string) =>
1455
+ session.log.ofType('plugin_event').filter((e) => e.pluginId === AGENT_CONNECTION_PLUGIN_ID && e.subtype === subtype).map((e) => e.payload);
1456
+
1457
+ it('lists a command Codex left running while it runs, records how it ended, and names it in the turn opened for its end', async () => {
1458
+ const command = { type: 'commandExecution', id: 'bg-1', command: "/bin/zsh -lc 'sleep 25 && echo BG-OK'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null };
1459
+ const { session, ids, later } = await leaveRunning((turn) => turn.started(command));
1460
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([{ id: 'bg-1', kind: 'command', label: 'sleep 25 && echo BG-OK' }]));
1461
+
1462
+ later('item/completed', { ...ids, item: { ...command, status: 'completed', aggregatedOutput: 'BG-OK\n', exitCode: 0 } });
1463
+ await vi.waitFor(() => expect(opened(session)).toHaveLength(1));
1464
+
1465
+ expect(workOf(session, 'background_work_ended')).toEqual([
1466
+ { provider: 'codex-agent', id: 'bg-1', kind: 'command', label: 'sleep 25 && echo BG-OK', status: 'completed', output: 'BG-OK\n' },
1467
+ ]);
1468
+ expect(session.getInfo().backgroundWork).toEqual([]);
1469
+ expect(opened(session)[0]?.origin).toMatchObject({ workId: 'bg-1' });
1470
+ });
1471
+
1472
+ it("lists a subagent going on after the turn, with what it says as it works, until it ends", async () => {
1473
+ const activity = (kind: string) => ({ type: 'subAgentActivity', id: `act-${kind}`, kind, agentThreadId: 'thread-child', agentPath: '/root/check_later' });
1474
+ const child = { threadId: 'thread-child', turnId: 'turn-child' };
1475
+ const { session, ids, later } = await leaveRunning((turn) => turn.server.notify('item/started', { threadId: turn.threadId, turnId: turn.id, item: activity('started') }));
1476
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([{ id: 'thread-child', kind: 'agent', label: 'check later' }]));
1477
+
1478
+ later('item/completed', { ...child, item: { type: 'agentMessage', id: 'msg-1', text: 'Checking the folder.', phase: 'commentary' } });
1479
+ await vi.waitFor(() => expect(session.log.ofType('plugin_event').some((e) => e.subtype === 'subagent_message')).toBe(true));
1480
+ expect(opened(session)).toHaveLength(0);
1481
+
1482
+ later('item/started', { ...ids, turnId: 'turn-x', item: activity('completed') });
1483
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([]));
1484
+ });
1485
+
1486
+ it('stops a command Codex left running, and says when it runs no such command', async () => {
1487
+ const command = { type: 'commandExecution', id: 'bg-1', command: "/bin/zsh -lc 'npm run dev'", cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null };
1488
+ const { codex, session, ids, later } = await leaveRunning((turn) => turn.started(command));
1489
+
1490
+ expect(await session.stopBackgroundWork('bg-1')).toBe(true);
1491
+ expect(await session.stopBackgroundWork('bg-9')).toBe(false);
1492
+ expect(codex.last.sent('thread/backgroundTerminals/terminate')).toEqual([expect.objectContaining({ threadId: 'thread-1' })]);
1493
+ expect([...codex.last.thread.terminals.keys()]).toEqual([]);
1494
+
1495
+ // A stopped command ends as stopped, and no turn of Codex follows it: the person ended it.
1496
+ later('item/completed', { ...ids, item: { ...command, status: 'failed', aggregatedOutput: 'ready', exitCode: null } });
1497
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([]));
1498
+ const ended = session.log.ofType('plugin_event').find((e) => e.subtype === 'background_work_ended');
1499
+ expect(ended?.payload).toMatchObject({ id: 'bg-1', status: 'stopped', output: 'ready' });
1500
+ await new Promise((resolve) => setTimeout(resolve, 50));
1501
+ expect(session.log.ofType('user_prompt')).toHaveLength(1);
1502
+ });
1503
+
1504
+ it("stops a subagent going on after the turn by interrupting the subagent's own turn", async () => {
1505
+ const activity = (kind: string) => ({ type: 'subAgentActivity', id: `act-${kind}`, kind, agentThreadId: 'thread-child', agentPath: '/root/check_later' });
1506
+ const { codex, session, ids, later } = await leaveRunning((turn) => turn.server.notify('item/started', { threadId: turn.threadId, turnId: turn.id, item: activity('started') }));
1507
+ later('turn/started', { threadId: 'thread-child', turn: { id: 'turn-child', status: 'inProgress' } });
1508
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toHaveLength(1));
1509
+
1510
+ expect(await session.stopBackgroundWork('thread-child')).toBe(true);
1511
+ expect(codex.last.sent('turn/interrupt')).toContainEqual({ threadId: 'thread-child', turnId: 'turn-child' });
1512
+ later('item/completed', { ...ids, item: activity('interrupted') });
1513
+
1514
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([]));
1515
+ const ended = session.log.ofType('plugin_event').find((e) => e.subtype === 'subagent_completed');
1516
+ expect(ended?.payload).toMatchObject({ childSessionId: 'thread-child', stopReason: 'stopped' });
1517
+ await new Promise((resolve) => setTimeout(resolve, 50));
1518
+ expect(session.log.ofType('user_prompt')).toHaveLength(1);
1519
+ });
1520
+
1521
+ it("stops a subagent whose own turn started while the conversation's turn still ran, as Codex sends it", async () => {
1522
+ const activity = (kind: string) => ({ type: 'subAgentActivity', id: `act-${kind}`, kind, agentThreadId: 'thread-child', agentPath: '/root/sleep_then_done' });
1523
+ const { codex, session, ids, later } = await leaveRunning((turn) => {
1524
+ turn.server.notify('item/started', { threadId: turn.threadId, turnId: turn.id, item: activity('started') });
1525
+ turn.server.notify('turn/started', { threadId: 'thread-child', turn: { id: 'turn-child', status: 'inProgress' } });
1526
+ });
1527
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toHaveLength(1));
1528
+
1529
+ expect(await session.stopBackgroundWork('thread-child')).toBe(true);
1530
+ expect(codex.last.sent('turn/interrupt')).toContainEqual({ threadId: 'thread-child', turnId: 'turn-child' });
1531
+ // Codex 0.163 ends only the subagent's own turn; the conversation's thread hears nothing.
1532
+ later('turn/completed', { threadId: 'thread-child', turn: { id: 'turn-child', status: 'interrupted' } });
1533
+
1534
+ await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([]));
1535
+ const ended = session.log.ofType('plugin_event').find((e) => e.subtype === 'subagent_completed');
1536
+ expect(ended?.payload).toMatchObject({ childSessionId: 'thread-child', stopReason: 'stopped' });
1537
+ later('item/completed', { ...ids, item: activity('interrupted') });
1538
+ await new Promise((resolve) => setTimeout(resolve, 50));
1539
+ expect(session.log.ofType('user_prompt')).toHaveLength(1);
1540
+ expect(session.log.ofType('plugin_event').filter((e) => e.subtype === 'subagent_completed')).toHaveLength(1);
1541
+ });
1542
+
1543
+ it('shows what finished while the person was already writing in their next turn instead', async () => {
1544
+ const command = { type: 'commandExecution', id: 'bg-2', command: 'sleep 1', cwd: '/work/app', status: 'inProgress', aggregatedOutput: null, exitCode: null };
1545
+ const { session, ids, later } = await leaveRunning((turn) => turn.started(command));
1546
+ const prompt = collectTurn(session, '2+2?');
1547
+ later('item/completed', { ...ids, item: { ...command, status: 'completed', aggregatedOutput: 'ok', exitCode: 0 } });
1548
+
1549
+ const next = await prompt;
1550
+ await new Promise((resolve) => setTimeout(resolve, 50));
1551
+
1552
+ expect(said(next)).toEqual(['4']);
1553
+ const shown = session.log.slice().filter((e) => e.type === 'tool_result' && e.ok && e.output === 'ok');
1554
+ expect(shown).toHaveLength(1);
1555
+ });
1556
+ });