Skip to content

Commit f517472

Browse files
Merge pull request #16 from moonbit-community/codex/native-json-events
Use native Codex JSON events
2 parents 24d4e1d + b3d8995 commit f517472

7 files changed

Lines changed: 630 additions & 30 deletions

File tree

README.mbt.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
This is the Codex SDK for MoonBit, ported from the TypeScript SDK.
44

55
The SDK communicates with Codex by spawning it in non-interactive mode using
6-
`codex exec`. The target Codex version is 0.79.0.
6+
`codex exec`. The target Codex version is `codex-cli 0.128.0`.
77

88
Codex must be installed and available on your PATH. If not, install with:
99

1010
```bash
11-
pnpm install -g @openai/codex@0.79.0
11+
pnpm install -g @openai/codex@0.128.0
1212
```
1313

1414
## Usage
@@ -81,7 +81,7 @@ async test {
8181

8282
The MoonBit SDK is a thin but strongly typed wrapper around `codex exec`:
8383

84-
1. `@codex.CodexExec::run` spawns the CLI with `--experimental-json`,
84+
1. `@codex.CodexExec::run` spawns the CLI with `--json`,
8585
automatically wiring API endpoint overrides, API keys, sandbox flags, working
8686
directory overrides, and thread resumption arguments.
8787
2. The CLI's JSONL stream is fed through `@generator.AsyncGenerator` so the SDK
@@ -91,6 +91,12 @@ The MoonBit SDK is a thin but strongly typed wrapper around `codex exec`:
9191
hierarchy (`events.mbt` and `items.mbt`), which means MoonBit callers never
9292
manipulate raw JSON.
9393

94+
The native JSON event parser targets the `codex-cli 0.128.0` non-interactive
95+
event stream, checked against the upstream `openai/codex` schema at
96+
`2a67c46de498`. In that schema, command execution items always include
97+
`aggregated_output` as a string; in-progress commands use an empty string until
98+
terminal output is available.
99+
94100
The `Codex`/`Thread`/`Turn` trio mirrors the CLI lifecycle: a `Codex` holds
95101
process-level configuration, a `Thread` models a Codex conversation, and a
96102
`Turn` captures the completed response plus token usage metrics.

events.mbt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ pub struct Usage {
126126
cached_input_tokens : Int
127127
/// The number of output tokens used during the turn
128128
output_tokens : Int
129-
} derive(ToJson, @json.FromJson)
129+
/// The number of reasoning output tokens used during the turn
130+
reasoning_output_tokens : Int
131+
} derive(ToJson, @json.FromJson, Debug)
130132

131133
///|
132134
pub struct ThreadError {

events_test.mbt

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
///|
2+
test "parse started command execution with empty output" {
3+
let event : Event = @json.from_json(
4+
@json.parse(
5+
(
6+
#|{
7+
#| "type": "item.started",
8+
#| "item": {
9+
#| "id": "cmd-1",
10+
#| "type": "command_execution",
11+
#| "command": "pwd",
12+
#| "aggregated_output": "",
13+
#| "status": "in_progress"
14+
#| }
15+
#|}
16+
),
17+
),
18+
)
19+
match event {
20+
ItemStarted(
21+
CommandExecutionItem(
22+
id~,
23+
command~,
24+
aggregated_output~,
25+
exit_code~,
26+
status~
27+
)
28+
) => {
29+
debug_inspect(
30+
id,
31+
content=(
32+
#|"cmd-1"
33+
),
34+
)
35+
debug_inspect(
36+
command,
37+
content=(
38+
#|"pwd"
39+
),
40+
)
41+
debug_inspect(
42+
aggregated_output,
43+
content=(
44+
#|""
45+
),
46+
)
47+
debug_inspect(exit_code, content="None")
48+
guard status is InProgress else {
49+
fail("expected in-progress command execution")
50+
}
51+
}
52+
_ => fail("expected started command execution")
53+
}
54+
}
55+
56+
///|
57+
test "parse declined command execution" {
58+
let event : Event = @json.from_json(
59+
@json.parse(
60+
(
61+
#|{
62+
#| "type": "item.completed",
63+
#| "item": {
64+
#| "id": "cmd-1",
65+
#| "type": "command_execution",
66+
#| "command": "pwd",
67+
#| "aggregated_output": "",
68+
#| "status": "declined"
69+
#| }
70+
#|}
71+
),
72+
),
73+
)
74+
match event {
75+
ItemCompleted(CommandExecutionItem(status~, exit_code~, ..)) => {
76+
guard status is Declined else {
77+
fail("expected declined command execution")
78+
}
79+
debug_inspect(exit_code, content="None")
80+
}
81+
_ => fail("expected completed command execution")
82+
}
83+
}
84+
85+
///|
86+
test "parse completed collab tool call" {
87+
let event : Event = @json.from_json(
88+
@json.parse(
89+
(
90+
#|{
91+
#| "type": "item.completed",
92+
#| "item": {
93+
#| "id": "collab-1",
94+
#| "type": "collab_tool_call",
95+
#| "tool": "spawn_agent",
96+
#| "sender_thread_id": "thread-parent",
97+
#| "receiver_thread_ids": ["thread-child"],
98+
#| "prompt": "draft a plan",
99+
#| "agents_states": {
100+
#| "thread-child": {
101+
#| "status": "running",
102+
#| "message": null
103+
#| }
104+
#| },
105+
#| "status": "completed"
106+
#| }
107+
#|}
108+
),
109+
),
110+
)
111+
match event {
112+
ItemCompleted(
113+
CollabToolCallItem(
114+
tool~,
115+
sender_thread_id~,
116+
receiver_thread_ids~,
117+
prompt~,
118+
status~,
119+
..
120+
)
121+
) => {
122+
guard tool is SpawnAgent else { fail("expected spawn_agent collab tool") }
123+
guard status is Completed else {
124+
fail("expected completed collab tool call")
125+
}
126+
debug_inspect(
127+
sender_thread_id,
128+
content=(
129+
#|"thread-parent"
130+
),
131+
)
132+
debug_inspect(
133+
receiver_thread_ids,
134+
content=(
135+
#|["thread-child"]
136+
),
137+
)
138+
debug_inspect(
139+
prompt,
140+
content=(
141+
#|Some("draft a plan")
142+
),
143+
)
144+
}
145+
_ => fail("expected completed collab tool call")
146+
}
147+
}
148+
149+
///|
150+
test "parse in-progress file change" {
151+
let event : Event = @json.from_json(
152+
@json.parse(
153+
(
154+
#|{
155+
#| "type": "item.started",
156+
#| "item": {
157+
#| "id": "patch-1",
158+
#| "type": "file_change",
159+
#| "changes": [
160+
#| { "path": "README.md", "kind": "update" }
161+
#| ],
162+
#| "status": "in_progress"
163+
#| }
164+
#|}
165+
),
166+
),
167+
)
168+
match event {
169+
ItemStarted(FileChangeItem(status~, changes~, ..)) => {
170+
guard status is InProgress else {
171+
fail("expected in-progress file change")
172+
}
173+
debug_inspect(
174+
changes.length(),
175+
content=(
176+
#|1
177+
),
178+
)
179+
}
180+
_ => fail("expected started file change")
181+
}
182+
}
183+
184+
///|
185+
test "parse in-progress mcp tool call with null result and error" {
186+
let event : Event = @json.from_json(
187+
@json.parse(
188+
(
189+
#|{
190+
#| "type": "item.started",
191+
#| "item": {
192+
#| "id": "mcp-1",
193+
#| "type": "mcp_tool_call",
194+
#| "server": "docs",
195+
#| "tool": "search",
196+
#| "arguments": { "query": "codex" },
197+
#| "result": null,
198+
#| "error": null,
199+
#| "status": "in_progress"
200+
#| }
201+
#|}
202+
),
203+
),
204+
)
205+
match event {
206+
ItemStarted(McpToolCallItem(result~, status~, ..)) => {
207+
guard status is InProgress else {
208+
fail("expected in-progress MCP tool call")
209+
}
210+
guard result is None else { fail("expected no MCP result") }
211+
}
212+
_ => fail("expected started MCP tool call")
213+
}
214+
}
215+
216+
///|
217+
test "parse turn completed usage with reasoning tokens" {
218+
let event : Event = @json.from_json(
219+
@json.parse(
220+
(
221+
#|{
222+
#| "type": "turn.completed",
223+
#| "usage": {
224+
#| "input_tokens": 10,
225+
#| "cached_input_tokens": 3,
226+
#| "output_tokens": 7,
227+
#| "reasoning_output_tokens": 5
228+
#| }
229+
#|}
230+
),
231+
),
232+
)
233+
match event {
234+
TurnCompleted(usage) =>
235+
debug_inspect(
236+
usage,
237+
content=(
238+
#|{
239+
#| input_tokens: 10,
240+
#| cached_input_tokens: 3,
241+
#| output_tokens: 7,
242+
#| reasoning_output_tokens: 5,
243+
#|}
244+
),
245+
)
246+
_ => fail("expected turn completed event")
247+
}
248+
}
249+
250+
///|
251+
test "parse failed mcp tool call with null result" {
252+
let event : Event = @json.from_json(
253+
@json.parse(
254+
(
255+
#|{
256+
#| "type": "item.completed",
257+
#| "item": {
258+
#| "id": "mcp-1",
259+
#| "type": "mcp_tool_call",
260+
#| "server": "docs",
261+
#| "tool": "search",
262+
#| "arguments": { "query": "codex" },
263+
#| "result": null,
264+
#| "error": { "message": "permission denied" },
265+
#| "status": "failed"
266+
#| }
267+
#|}
268+
),
269+
),
270+
)
271+
match event {
272+
ItemCompleted(McpToolCallItem(result~, status~, ..)) => {
273+
guard status is Failed else { fail("expected failed MCP tool call") }
274+
guard result is Some(Err("permission denied")) else {
275+
fail("expected MCP error result")
276+
}
277+
}
278+
_ => fail("expected completed MCP tool call")
279+
}
280+
}

exec.mbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async fn[T] CodexExec::run(
6868
taskgroup : @async.TaskGroup[T],
6969
) -> @generator.AsyncGenerator[String] {
7070
// Build command arguments
71-
let command_args : Array[String] = ["exec", "--experimental-json"]
71+
let command_args : Array[String] = ["exec", "--json"]
7272

7373
// Add optional arguments
7474
if args.thread_options.model is Some(model) {

0 commit comments

Comments
 (0)