@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.46.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,256 @@
1
+ /**
2
+ * The part of the `codex app-server` protocol Moxxy speaks, written by hand
3
+ * from `codex app-server generate-ts` (0.155.1, checked against 0.163.0). Codex sends far more than
4
+ * this; anything not listed here is read as unknown and left alone.
5
+ */
6
+
7
+ export type CodexApprovalPolicy =
8
+ | 'untrusted'
9
+ | 'on-request'
10
+ | 'never'
11
+ | { readonly granular: Readonly<Record<string, boolean>> };
12
+
13
+ export type CodexSandboxPolicy =
14
+ | { readonly type: 'dangerFullAccess' }
15
+ | { readonly type: 'readOnly'; readonly networkAccess?: boolean }
16
+ | { readonly type: 'workspaceWrite'; readonly [key: string]: unknown }
17
+ | { readonly type: 'externalSandbox'; readonly [key: string]: unknown };
18
+
19
+ /** The thread's settings in `thread/settings/updated` (only what Moxxy reads). */
20
+ export interface CodexThreadSettingsReport {
21
+ readonly approvalPolicy: CodexApprovalPolicy;
22
+ readonly approvalsReviewer: CodexApprovalsReviewer;
23
+ readonly sandboxPolicy: CodexSandboxPolicy;
24
+ }
25
+
26
+ /** What `thread/start` and `thread/resume` answer: the thread as Codex really runs it. */
27
+ export interface CodexThreadResponse {
28
+ readonly thread: { readonly id: string };
29
+ readonly model: string;
30
+ readonly approvalPolicy: CodexApprovalPolicy;
31
+ readonly sandbox: CodexSandboxPolicy;
32
+ readonly approvalsReviewer?: CodexApprovalsReviewer;
33
+ readonly reasoningEffort: string | null;
34
+ readonly serviceTier?: string | null;
35
+ }
36
+
37
+ /** Who answers Codex's approvals: the person, or Codex's own reviewer. */
38
+ export type CodexApprovalsReviewer = 'user' | 'auto_review' | 'guardian_subagent';
39
+
40
+ /** What `config/read` answers: the person's effective Codex config, as far as Moxxy reads it. */
41
+ /** The person's Codex config as `config/read` reports it for the folder (only what Moxxy reads). */
42
+ /** `skills/list`: the skills Codex has where each folder asked for runs (generated `SkillsListResponse`, fields Moxxy reads). */
43
+ export interface CodexSkillsList {
44
+ readonly data: ReadonlyArray<{
45
+ readonly cwd: string;
46
+ readonly skills: ReadonlyArray<{ readonly name: string; readonly description: string; readonly enabled: boolean }>;
47
+ }>;
48
+ }
49
+
50
+ /**
51
+ * `thread/backgroundTerminals/list` (experimental, 0.155.1 and later): the
52
+ * commands still running in a thread, by the item that started them. An
53
+ * interrupted turn's commands stay here until `terminate` ends them.
54
+ */
55
+ export interface CodexBackgroundTerminals {
56
+ readonly data: ReadonlyArray<{ readonly itemId: string; readonly processId: string }>;
57
+ readonly nextCursor: string | null;
58
+ }
59
+
60
+ export interface CodexConfigRead {
61
+ readonly config: {
62
+ readonly model_reasoning_summary?: string | null;
63
+ readonly approval_policy?: CodexApprovalPolicy | null;
64
+ readonly sandbox_mode?: 'read-only' | 'workspace-write' | 'danger-full-access' | null;
65
+ readonly sandbox_workspace_write?: {
66
+ readonly writable_roots: ReadonlyArray<string>;
67
+ readonly network_access: boolean;
68
+ readonly exclude_tmpdir_env_var: boolean;
69
+ readonly exclude_slash_tmp: boolean;
70
+ } | null;
71
+ readonly approvals_reviewer?: CodexApprovalsReviewer | null;
72
+ readonly tools?: { readonly update_plan?: { readonly enabled?: boolean } | null } | null;
73
+ /** The person's own MCP servers, by name. */
74
+ readonly mcp_servers?: Readonly<Record<string, { readonly enabled?: boolean }>> | null;
75
+ };
76
+ }
77
+
78
+ /** `model/list`: one page of the models Codex offers the account (generated `ModelListResponse`, fields Moxxy reads). */
79
+ export interface CodexModelList {
80
+ readonly data: ReadonlyArray<{
81
+ readonly id: string;
82
+ readonly hidden?: boolean;
83
+ readonly inputModalities?: ReadonlyArray<string>;
84
+ readonly supportedReasoningEfforts?: ReadonlyArray<unknown>;
85
+ readonly additionalSpeedTiers?: ReadonlyArray<string>;
86
+ }>;
87
+ readonly nextCursor?: string | null;
88
+ }
89
+
90
+ export type CodexUserInput =
91
+ | { readonly type: 'text'; readonly text: string; readonly text_elements: ReadonlyArray<never> }
92
+ | { readonly type: 'image'; readonly url: string }
93
+ | { readonly type: 'localImage'; readonly path: string };
94
+
95
+ export type CodexCollaborationMode = {
96
+ readonly mode: 'plan' | 'default';
97
+ readonly settings: { readonly model: string; readonly reasoning_effort: string | null; readonly developer_instructions: null };
98
+ };
99
+
100
+ /** Fields of `thread/settings/update`; each applies to the thread's later turns. */
101
+ export interface CodexThreadSettings {
102
+ readonly approvalPolicy?: CodexApprovalPolicy;
103
+ readonly sandboxPolicy?: CodexSandboxPolicy;
104
+ readonly model?: string;
105
+ readonly effort?: string;
106
+ readonly collaborationMode?: CodexCollaborationMode;
107
+ }
108
+
109
+ export type CodexTurnStatus = 'completed' | 'interrupted' | 'failed' | 'inProgress';
110
+
111
+ export interface CodexTurn {
112
+ readonly id: string;
113
+ readonly status: CodexTurnStatus;
114
+ /** `codexErrorInfo` is Codex's code for the failure: a string (`usageLimitExceeded`) or an object for some. */
115
+ readonly error?: { readonly message: string; readonly codexErrorInfo?: unknown } | null;
116
+ }
117
+
118
+ /** `resetsAt` is in epoch seconds. */
119
+ export interface CodexRateLimitWindow {
120
+ readonly usedPercent: number;
121
+ readonly windowDurationMins: number | null;
122
+ readonly resetsAt: number | null;
123
+ }
124
+
125
+ /** `account/rateLimits/updated`: a sparse update, where a missing window keeps the one seen before. */
126
+ export interface CodexRateLimitSnapshot {
127
+ readonly primary?: CodexRateLimitWindow | null;
128
+ readonly secondary?: CodexRateLimitWindow | null;
129
+ readonly rateLimitReachedType?: string | null;
130
+ }
131
+
132
+ export type CodexGoalStatus = 'active' | 'paused' | 'blocked' | 'usageLimited' | 'budgetLimited' | 'complete';
133
+
134
+ export interface CodexGoal {
135
+ readonly objective: string;
136
+ readonly status: CodexGoalStatus;
137
+ }
138
+
139
+ /** A question Codex's plan mode asks inside its message (`delivery: 'async'`). */
140
+ export interface CodexAsyncQuestion {
141
+ readonly title: string;
142
+ readonly options: ReadonlyArray<string> | null;
143
+ }
144
+
145
+ export type CodexItem =
146
+ | {
147
+ readonly type: 'agentMessage';
148
+ readonly id: string;
149
+ readonly text: string;
150
+ readonly phase?: string | null;
151
+ readonly delivery?: 'async' | null;
152
+ readonly questions?: ReadonlyArray<CodexAsyncQuestion> | null;
153
+ }
154
+ | { readonly type: 'plan'; readonly id: string; readonly text: string }
155
+ | { readonly type: 'reasoning'; readonly id: string; readonly summary: ReadonlyArray<string>; readonly content: ReadonlyArray<string> }
156
+ | {
157
+ readonly type: 'commandExecution';
158
+ readonly id: string;
159
+ readonly command: string;
160
+ readonly cwd: string;
161
+ readonly status: string;
162
+ readonly aggregatedOutput: string | null;
163
+ readonly exitCode: number | null;
164
+ readonly commandActions?: ReadonlyArray<{ readonly command?: string }>;
165
+ }
166
+ | {
167
+ readonly type: 'fileChange';
168
+ readonly id: string;
169
+ readonly status: string;
170
+ readonly changes: ReadonlyArray<CodexFileChange>;
171
+ }
172
+ | {
173
+ readonly type: 'mcpToolCall';
174
+ readonly id: string;
175
+ readonly server: string;
176
+ readonly tool: string;
177
+ readonly status: string;
178
+ readonly arguments: unknown;
179
+ readonly result: unknown;
180
+ readonly error: { readonly message: string } | null;
181
+ }
182
+ | { readonly type: 'webSearch'; readonly id: string; readonly query: string; readonly action?: CodexWebSearchAction | null }
183
+ | { readonly type: 'imageView'; readonly id: string; readonly path: string }
184
+ | { readonly type: 'sleep'; readonly id: string; readonly durationMs: number }
185
+ | {
186
+ readonly type: 'imageGeneration';
187
+ readonly id: string;
188
+ readonly status: string;
189
+ readonly revisedPrompt: string | null;
190
+ /** The picture, base64. */
191
+ readonly result: string;
192
+ readonly failure: { readonly type: string } | null;
193
+ readonly savedPath?: string;
194
+ };
195
+
196
+ export type CodexWebSearchAction =
197
+ | { readonly type: 'search'; readonly query?: string; readonly queries?: ReadonlyArray<string> }
198
+ | { readonly type: 'open_page'; readonly url?: string }
199
+ | { readonly type: 'find_in_page'; readonly url?: string; readonly pattern?: string }
200
+ | { readonly type: 'other' };
201
+
202
+ /** One file of a `fileChange`: for `add` the diff is the new content, for `update` a unified diff; `move_path` names where an update moved it. */
203
+ export interface CodexFileChange {
204
+ readonly path: string;
205
+ readonly kind: { readonly type: string; readonly move_path?: string | null };
206
+ readonly diff?: string;
207
+ }
208
+
209
+ const KNOWN_ITEMS = new Set<string>(['agentMessage', 'plan', 'reasoning', 'commandExecution', 'fileChange', 'mcpToolCall', 'webSearch', 'imageView', 'sleep', 'imageGeneration']);
210
+
211
+ /** Items Moxxy leaves out of the chat on purpose, and why. Every other kind shows: one Moxxy does not know, as a call named after it. */
212
+ export const CODEX_ITEMS_LEFT_OUT: Readonly<Record<string, string>> = {
213
+ userMessage: "the person's own message, which Moxxy already shows",
214
+ hookPrompt: "text the person's Codex hooks add to the prompt, not something Codex did",
215
+ contextCompaction: "Codex shortening its own context; Moxxy's record of the conversation stays whole",
216
+ collabAgentToolCall: "Codex starting, waiting on or messaging its subagents, which their own rows show",
217
+ subAgentActivity: 'shown as the subagent row, read before the turn items',
218
+ };
219
+
220
+ /** An item Moxxy reads, or `undefined` for one it shows as a plain call or leaves out. */
221
+ export function knownItem(value: unknown): CodexItem | undefined {
222
+ if (typeof value !== 'object' || value === null) return undefined;
223
+ const { type, id } = value as { type?: unknown; id?: unknown };
224
+ return typeof type === 'string' && typeof id === 'string' && KNOWN_ITEMS.has(type) ? (value as CodexItem) : undefined;
225
+ }
226
+
227
+ /** An item of a kind Moxxy neither reads nor leaves out: its fields, without the id, kind and status. */
228
+ export interface CodexOtherItem {
229
+ readonly type: string;
230
+ readonly id: string;
231
+ readonly status?: string;
232
+ readonly fields: Readonly<Record<string, unknown>>;
233
+ }
234
+
235
+ export function otherItem(value: unknown): CodexOtherItem | undefined {
236
+ if (typeof value !== 'object' || value === null || knownItem(value)) return undefined;
237
+ const { type, id, status, ...fields } = value as Record<string, unknown>;
238
+ if (typeof type !== 'string' || typeof id !== 'string' || type in CODEX_ITEMS_LEFT_OUT) return undefined;
239
+ return { type, id, ...(typeof status === 'string' ? { status } : {}), fields };
240
+ }
241
+
242
+ export interface CodexTokenUsage {
243
+ readonly inputTokens: number;
244
+ readonly cachedInputTokens: number;
245
+ readonly outputTokens: number;
246
+ }
247
+
248
+ /** `item/tool/requestUserInput` (experimental): a blocking question with ids. */
249
+ export interface CodexUserInputQuestion {
250
+ readonly id: string;
251
+ readonly header: string;
252
+ readonly question: string;
253
+ readonly isOther: boolean;
254
+ readonly isSecret: boolean;
255
+ readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> | null;
256
+ }
@@ -0,0 +1,47 @@
1
+ /** Codex's subagents, shaped as Codex 0.155 reports them: activity items on the conversation's thread, the work on the subagent's own. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { CodexSubagents } from './subagents.js';
4
+
5
+ const CHILD = 'thread-child';
6
+ const activity = (kind: 'started' | 'completed') => ({ type: 'subAgentActivity', id: `act-${kind}`, kind, agentThreadId: CHILD, agentPath: '/root/inspect_folder' });
7
+ const onChild = (item: Record<string, unknown>) => ({ threadId: CHILD, turnId: 'turn-child', item });
8
+
9
+ describe("Codex's subagents", () => {
10
+ it('start once, named after their path, however often Codex reports the start', () => {
11
+ const subagents = new CodexSubagents();
12
+
13
+ expect(subagents.activity(activity('started'))).toEqual([{ type: 'subagent_started', id: CHILD, label: 'inspect folder', agentType: 'default' }]);
14
+ expect(subagents.activity(activity('started'))).toEqual([]);
15
+ });
16
+
17
+ it('show the tools a subagent runs in its own thread, and end with its answer and tokens', () => {
18
+ const subagents = new CodexSubagents();
19
+ subagents.activity(activity('started'));
20
+
21
+ const ran = subagents.fromThread('item/completed', onChild({ type: 'commandExecution', id: 'exec-1', command: "/bin/zsh -lc 'ls -la'", cwd: '/repo', status: 'completed', aggregatedOutput: 'a.txt', exitCode: 0 }));
22
+ subagents.fromThread('item/completed', onChild({ type: 'agentMessage', id: 'msg-1', text: 'Two files.', phase: 'final_answer' }));
23
+ subagents.fromThread('thread/tokenUsage/updated', { threadId: CHILD, turnId: 'turn-child', tokenUsage: { total: { totalTokens: 47282 }, last: { totalTokens: 23747 } } });
24
+
25
+ expect(ran).toEqual([{ type: 'subagent_tool_call', id: CHILD, name: 'Bash', input: { command: 'ls -la', cwd: '/repo' } }]);
26
+ expect(subagents.activity(activity('completed'))).toEqual([{ type: 'subagent_completed', id: CHILD, text: 'Two files.', tokensUsed: 47282 }]);
27
+ expect(subagents.activity(activity('completed'))).toEqual([]);
28
+ });
29
+
30
+ it("end as stopped when their own turn is interrupted, since Codex reports no interruption on the conversation's thread between turns", () => {
31
+ const subagents = new CodexSubagents();
32
+ subagents.activity(activity('started'));
33
+ subagents.fromThread('turn/started', { threadId: CHILD, turn: { id: 'turn-child', status: 'inProgress' } });
34
+
35
+ expect(subagents.fromThread('turn/completed', { threadId: CHILD, turn: { id: 'turn-child', status: 'interrupted' } })).toEqual([
36
+ { type: 'subagent_completed', id: CHILD, text: '', error: 'The subagent was stopped.', stopped: true },
37
+ ]);
38
+ expect(subagents.running()).toEqual([]);
39
+ expect(subagents.activity({ ...activity('completed'), kind: 'interrupted' })).toEqual([]);
40
+ });
41
+
42
+ it('leave out threads that are not a subagent Codex announced', () => {
43
+ const subagents = new CodexSubagents();
44
+
45
+ expect(subagents.fromThread('item/completed', onChild({ type: 'commandExecution', id: 'exec-1', command: 'ls', cwd: '/repo', status: 'completed' }))).toEqual([]);
46
+ });
47
+ });
@@ -0,0 +1,98 @@
1
+ import type { AgentBackgroundWork, AgentStepEvent } from '@moxxy/sdk';
2
+ import { toolCallOf } from './translate.js';
3
+ import type { CodexItem } from './protocol.js';
4
+
5
+ interface Running {
6
+ readonly label: string;
7
+ text: string;
8
+ tokens: number | undefined;
9
+ done: boolean;
10
+ /** The turn running in its thread now, which Stop interrupts. */
11
+ turn?: string;
12
+ }
13
+
14
+ /**
15
+ * Codex's subagents. Codex announces one with a `subAgentActivity` item on the conversation's
16
+ * thread and runs it in a thread of its own; its tool calls and answer come from that thread.
17
+ * They outlive a Moxxy turn, so the session keeps them.
18
+ */
19
+ export class CodexSubagents {
20
+ private readonly agents = new Map<string, Running>();
21
+
22
+ /** A `subAgentActivity` item on the conversation's thread. */
23
+ activity(item: unknown): AgentStepEvent[] {
24
+ const a = (item ?? {}) as { kind?: unknown; agentThreadId?: unknown; agentPath?: unknown };
25
+ if (typeof a.agentThreadId !== 'string') return [];
26
+ const id = a.agentThreadId;
27
+ const known = this.agents.get(id);
28
+ if (a.kind === 'started') {
29
+ if (known) return [];
30
+ const label = labelOf(a.agentPath);
31
+ this.agents.set(id, { label, text: '', tokens: undefined, done: false });
32
+ return [{ type: 'subagent_started', id, label, agentType: 'default' }];
33
+ }
34
+ if ((a.kind !== 'completed' && a.kind !== 'interrupted') || !known || known.done) return [];
35
+ return [end(id, known, a.kind === 'interrupted')];
36
+ }
37
+
38
+ /** A notification from a thread other than the conversation's: a subagent's work, if it is one. */
39
+ fromThread(method: string, params: Record<string, unknown>): AgentStepEvent[] {
40
+ const id = typeof params.threadId === 'string' ? params.threadId : '';
41
+ const agent = this.agents.get(id);
42
+ if (!agent || agent.done) return [];
43
+ if (method === 'turn/started' || method === 'turn/completed') {
44
+ const turn = params.turn as { id?: unknown; status?: unknown } | undefined;
45
+ if (method === 'turn/started') {
46
+ if (typeof turn?.id === 'string') agent.turn = turn.id;
47
+ return [];
48
+ }
49
+ delete agent.turn;
50
+ // Between the conversation's turns Codex reports a stopped subagent only here.
51
+ return turn?.status === 'interrupted' ? [end(id, agent, true)] : [];
52
+ }
53
+ if (method === 'thread/tokenUsage/updated') {
54
+ const total = (params.tokenUsage as { total?: { totalTokens?: unknown } } | undefined)?.total;
55
+ if (total && typeof total.totalTokens === 'number') agent.tokens = total.totalTokens;
56
+ return [];
57
+ }
58
+ if (method !== 'item/completed') return [];
59
+ const item = params.item as CodexItem | undefined;
60
+ if (!item) return [];
61
+ if (item.type === 'agentMessage') {
62
+ if (!item.text) return [];
63
+ agent.text = item.text;
64
+ // The final answer shows as the subagent's answer when it ends.
65
+ return (item as { phase?: unknown }).phase === 'final_answer' ? [] : [{ type: 'subagent_message', id, text: item.text }];
66
+ }
67
+ const call = toolCallOf(item);
68
+ return call ? [{ type: 'subagent_tool_call', id, name: call.name, input: call.input }] : [];
69
+ }
70
+
71
+ /** The subagents still working, as background work. */
72
+ running(): AgentBackgroundWork[] {
73
+ return [...this.agents].flatMap(([id, agent]) => (agent.done ? [] : [{ id, kind: 'agent' as const, label: agent.label }]));
74
+ }
75
+
76
+ /** The turn running in a working subagent's thread, if any. */
77
+ turnOf(id: string): string | undefined {
78
+ const agent = this.agents.get(id);
79
+ return agent && !agent.done ? agent.turn : undefined;
80
+ }
81
+ }
82
+
83
+ function end(id: string, agent: Running, stopped: boolean): AgentStepEvent {
84
+ agent.done = true;
85
+ return {
86
+ type: 'subagent_completed',
87
+ id,
88
+ text: agent.text,
89
+ ...(agent.tokens !== undefined ? { tokensUsed: agent.tokens } : {}),
90
+ ...(stopped ? { error: 'The subagent was stopped.', stopped: true as const } : {}),
91
+ };
92
+ }
93
+
94
+ /** "/root/inspect_folder" → "inspect folder". */
95
+ export function labelOf(path: unknown): string {
96
+ const last = typeof path === 'string' ? path.split('/').filter(Boolean).pop() : undefined;
97
+ return last ? last.replace(/[_-]+/g, ' ') : 'subagent';
98
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { TurnTranslator } from './translate.js';
3
+
4
+ describe("Codex's web searches", () => {
5
+ it('are shown once finished, with the query Codex only knows by then', () => {
6
+ const translator = new TurnTranslator();
7
+ const started = { type: 'webSearch', id: 'ws1', query: '', action: null, results: null };
8
+ expect(translator.translate('item/started', { item: started })).toEqual([]);
9
+
10
+ const completed = { ...started, action: { type: 'search', query: 'frankfurter api v2' } };
11
+ expect(translator.translate('item/completed', { item: completed })).toEqual([
12
+ { type: 'tool_call', callId: 'ws1', name: 'web_search', input: { query: 'frankfurter api v2' } },
13
+ { type: 'tool_result', callId: 'ws1', ok: true, output: '' },
14
+ ]);
15
+ });
16
+
17
+ it('show the page Codex opened', () => {
18
+ const translator = new TurnTranslator();
19
+ const item = { type: 'webSearch', id: 'ws2', query: '', action: { type: 'open_page', url: 'https://frankfurter.dev/' }, results: null };
20
+ expect(translator.translate('item/completed', { item })[0]).toEqual({
21
+ type: 'tool_call',
22
+ callId: 'ws2',
23
+ name: 'web_search',
24
+ input: { url: 'https://frankfurter.dev/' },
25
+ });
26
+ });
27
+ });
28
+
29
+ describe("Codex's file changes", () => {
30
+ const change = (path: string, kind: Record<string, unknown>, diff: string) => ({ path, kind, diff });
31
+ const completed = (id: string, changes: unknown[]) => ({ type: 'fileChange', id, status: 'completed', changes });
32
+
33
+ it('show a change to one file as a diff, from the unified diff Codex sends', () => {
34
+ const translator = new TurnTranslator('/repo');
35
+ const diff = '@@ -1,2 +1,2 @@\n const host = "x";\n-const port = 5432;\n+const port = 5433;\n';
36
+ const [, result] = translator.translate('item/completed', { item: completed('fc1', [change('/repo/src/db.ts', { type: 'update', move_path: null }, diff)]) });
37
+
38
+ expect(result).toMatchObject({
39
+ type: 'tool_result',
40
+ callId: 'fc1',
41
+ ok: true,
42
+ output: { forModel: diff, display: { kind: 'file-diff', path: 'src/db.ts', mode: 'update', added: 1, removed: 1 } },
43
+ });
44
+ });
45
+
46
+ it('show a new file as all added, from the content Codex sends for it', () => {
47
+ const translator = new TurnTranslator('/repo');
48
+ const [, result] = translator.translate('item/completed', { item: completed('fc2', [change('/repo/notes.md', { type: 'add' }, '# Notes\nport 5433\n')]) });
49
+
50
+ expect(result).toMatchObject({ output: { display: { kind: 'file-diff', path: 'notes.md', mode: 'create', added: 2, removed: 0 } } });
51
+ });
52
+
53
+ it('keep a change to several files, or a deleted file, as the text Codex sends', () => {
54
+ const translator = new TurnTranslator('/repo');
55
+ const two = completed('fc3', [change('/repo/a.ts', { type: 'add' }, 'a\n'), change('/repo/b.ts', { type: 'add' }, 'b\n')]);
56
+ const removed = completed('fc4', [change('/repo/old.ts', { type: 'delete' }, 'gone\n')]);
57
+
58
+ expect(translator.translate('item/completed', { item: two })[1]).toMatchObject({ ok: true, output: 'a\n\nb\n' });
59
+ expect(translator.translate('item/completed', { item: removed })[1]).toMatchObject({ ok: true, output: 'gone\n' });
60
+ });
61
+ });
62
+
63
+ describe("Codex's images", () => {
64
+ it('keep the screenshot an MCP tool returned, once, with its text', () => {
65
+ const translator = new TurnTranslator('/repo');
66
+ const shot = { type: 'image', data: '/9j/AAA', mimeType: 'image/jpeg', _meta: { 'codex/imageDetail': 'original' } };
67
+ const item = {
68
+ type: 'mcpToolCall',
69
+ id: 'm1',
70
+ server: 'cua_repl',
71
+ tool: 'js',
72
+ status: 'completed',
73
+ arguments: { code: 'await nodeRepl.emitImage(await app.getScreenshot());', title: 'Oglądam testy w przeglądarce' },
74
+ result: { content: [{ type: 'text', text: 'Arc, tests.html' }, shot, shot], structuredContent: null, _meta: {} },
75
+ error: null,
76
+ };
77
+
78
+ expect(translator.translate('item/completed', { item })[1]).toEqual({
79
+ type: 'tool_result',
80
+ callId: 'm1',
81
+ ok: true,
82
+ output: { forModel: 'Arc, tests.html', mediaType: 'image/jpeg', base64: '/9j/AAA' },
83
+ });
84
+ });
85
+
86
+ it('keep every different screenshot an MCP tool returned, each once, in order', () => {
87
+ const translator = new TurnTranslator('/repo');
88
+ const shot = (data: string) => ({ type: 'image', data, mimeType: 'image/jpeg' });
89
+ const content = [{ type: 'text', text: 'Three windows' }, shot('/9j/ONE'), shot('/9j/ONE'), shot('/9j/TWO'), shot('/9j/TWO'), shot('/9j/THREE')];
90
+ const item = { type: 'mcpToolCall', id: 'm3', server: 'cua_repl', tool: 'js', status: 'completed', arguments: {}, result: { content }, error: null };
91
+
92
+ expect(translator.translate('item/completed', { item })[1]).toMatchObject({
93
+ output: {
94
+ forModel: 'Three windows',
95
+ base64: '/9j/ONE',
96
+ moreImages: [
97
+ { mediaType: 'image/jpeg', base64: '/9j/TWO' },
98
+ { mediaType: 'image/jpeg', base64: '/9j/THREE' },
99
+ ],
100
+ },
101
+ });
102
+ });
103
+
104
+ it('keep the result of an MCP tool without an image as Codex sent it', () => {
105
+ const translator = new TurnTranslator('/repo');
106
+ const result = { content: [{ type: 'text', text: 'ok' }], structuredContent: null };
107
+ const item = { type: 'mcpToolCall', id: 'm2', server: 'docs', tool: 'search', status: 'completed', arguments: {}, result, error: null };
108
+
109
+ expect(translator.translate('item/completed', { item })[1]).toMatchObject({ ok: true, output: result });
110
+ });
111
+
112
+ it('show the image file Codex viewed, read from disk', () => {
113
+ const read: string[] = [];
114
+ const translator = new TurnTranslator('/repo', (path) => {
115
+ read.push(path);
116
+ return { mediaType: 'image/png', base64: 'iVBOR' };
117
+ });
118
+
119
+ expect(translator.translate('item/completed', { item: { type: 'imageView', id: 'v1', path: '/repo/shot.png' } })).toEqual([
120
+ { type: 'tool_call', callId: 'v1', name: 'view_image', input: { path: '/repo/shot.png' } },
121
+ { type: 'tool_result', callId: 'v1', ok: true, output: { forModel: '/repo/shot.png', mediaType: 'image/png', base64: 'iVBOR' } },
122
+ ]);
123
+ expect(read).toEqual(['/repo/shot.png']);
124
+ });
125
+ });
126
+
127
+ describe("Codex's checklist", () => {
128
+ it('shows each plan update as a call whose result is the whole list, with why Codex changed it', () => {
129
+ const translator = new TurnTranslator();
130
+ const plan = [
131
+ { step: 'Create a.txt', status: 'completed' },
132
+ { step: 'Create b.txt', status: 'inProgress' },
133
+ { step: 'Create c.txt', status: 'pending' },
134
+ ];
135
+
136
+ const events = translator.translate('turn/plan/updated', { threadId: 't1', turnId: 'turn-1', explanation: 'a.txt is done', plan });
137
+
138
+ expect(events).toEqual([
139
+ { type: 'tool_call', callId: 'plan-turn-1-1', name: 'update_plan', input: { plan, explanation: 'a.txt is done' } },
140
+ {
141
+ type: 'tool_result',
142
+ callId: 'plan-turn-1-1',
143
+ ok: true,
144
+ output: {
145
+ forModel: '[x] Create a.txt\n[ ] Create b.txt\n[ ] Create c.txt',
146
+ display: {
147
+ kind: 'step-list',
148
+ explanation: 'a.txt is done',
149
+ steps: [
150
+ { text: 'Create a.txt', status: 'completed' },
151
+ { text: 'Create b.txt', status: 'in_progress' },
152
+ { text: 'Create c.txt', status: 'pending' },
153
+ ],
154
+ },
155
+ },
156
+ },
157
+ ]);
158
+ expect(translator.translate('turn/plan/updated', { threadId: 't1', turnId: 'turn-1', explanation: null, plan })[0]).toMatchObject({ callId: 'plan-turn-1-2' });
159
+ });
160
+ });