Skip to content

Commit ac6b3b2

Browse files
committed
test codex and extend model selection
1 parent 424b52f commit ac6b3b2

18 files changed

Lines changed: 379 additions & 68 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ When touching the canvas, read kuiver's sources rather than its README:
266266
unzip ~/.gradle/caches/modules-2/files-2.1/io.github.justdeko/kuiver-jvm/*/*/core-jvm-*-sources.jar
267267
```
268268

269-
Material 3's expressive APIs are opted into once for the whole `:shared` source set in its
270-
`build.gradle.kts`, not per file — the theme is app-wide.
269+
Material 3's experimental and expressive APIs are opted into once for the whole `:shared` source set
270+
in its `build.gradle.kts`, not per file — the theme is app-wide.
271271

272272
`.mcp.json` wires up `:desktopApp:hotMcpServer`, which drives a running hot-reload instance —
273273
screenshots, the semantic tree, clicks. That is how to check a UI change actually looks right rather

‎README.md‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,15 @@ instead. There's also a CLI which allows you to run zopf workflows headlessly.
3737

3838
You need **macOS on Apple Silicon** and one of these installed and signed in:
3939

40-
| CLI | `provider:` | What zopf runs |
41-
|--------------------------------------------------------------------------------------|-------------|-----------------------------|
42-
| [Claude Code](https://code.claude.com/docs/en/quickstart#step-1-install-claude-code) | `claude` | `claude -p`, streaming JSON |
43-
| [Codex](https://learn.chatgpt.com/docs/codex/cli) | `codex` | `codex exec --json` |
44-
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | `dsh --profile headless` |
40+
| CLI | `provider:` | What zopf runs |
41+
|---------------------------------------------------------------------|-------------|-----------------------------|
42+
| [Claude Code](https://github.com/anthropics/claude-code) | `claude` | `claude -p`, streaming JSON |
43+
| [Codex](https://github.com/openai/codex) | `codex` | `codex exec --json` |
44+
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | `dsh --profile headless` |
4545

4646
> [!NOTE]
47-
> So far, only the Claude Code path was used daily and tested against a real CLI. The codex and dsh paths are built from
48-
those
49-
> CLIs' documented flags and covered by unit tests, but they haven't been tested end to end here.
50-
> Please open an issue if you notice a bug in their implementations.
47+
> While zopf supports claude code, codex and dsh so far, I've only extensively tested claude code as it is my daily
48+
driver.
5149

5250
### The App
5351

@@ -168,7 +166,8 @@ defaults:
168166
repo: self
169167
```
170168

171-
The run archive grows, so a chatty build step can run quite large. To cap it, go to Settings → History, or do `zopf prune --keep 50`.
169+
The run archive grows, so a chatty build step can run quite large. To cap it, go to Settings → History, or do
170+
`zopf prune --keep 50`.
172171

173172
## CLI usage
174173

‎core/src/commonMain/kotlin/com/dk/zopf/model/AgentCapabilities.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ val AgentProviderId.capabilities: AgentCapabilities
3535
AgentCapabilities(
3636
followUps = false,
3737
inlineApproval = false,
38-
resumeInTerminal = false,
38+
resumeInTerminal = true,
3939
reportsCostUsd = false,
4040
skills = false,
4141
toolPermissions = false,
4242
extraDirectories = false,
43-
outputSchema = false,
43+
outputSchema = true,
4444
sandbox = true,
4545
modelSelection = true,
4646
)
@@ -66,7 +66,7 @@ val AgentProviderId.modelOptions: List<String>
6666
get() =
6767
when (this) {
6868
AgentProviderId.CLAUDE -> listOf("opus", "sonnet", "haiku")
69-
AgentProviderId.CODEX -> emptyList()
69+
AgentProviderId.CODEX -> listOf("gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4-mini")
7070
AgentProviderId.DSH -> emptyList()
7171
}
7272

‎core/src/commonMain/kotlin/com/dk/zopf/model/OutputSchema.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,20 @@ fun List<SchemaField>.toJsonSchema(): JsonObject {
4646
putJsonObject("properties") {
4747
fields.forEach { field ->
4848
putJsonObject(field.name) {
49-
put("type", field.type.jsonType)
49+
if (field.required) {
50+
put("type", field.type.jsonType)
51+
} else {
52+
putJsonArray("type") {
53+
add(field.type.jsonType)
54+
add("null")
55+
}
56+
}
5057
if (field.description.isNotBlank()) put("description", field.description)
5158
}
5259
}
5360
}
5461
putJsonArray("required") {
55-
fields.filter { it.required }.forEach { add(it.name) }
62+
fields.forEach { add(it.name) }
5663
}
5764
put("additionalProperties", false)
5865
}

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/CodexEvents.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ object CodexEvents {
105105
AgentEvent.AssistantMessage(threadId, null, listOf(ContentBlock.Thinking(item.text())), obj)
106106
}
107107

108+
itemType == "error" && complete -> {
109+
AgentEvent.Notice(threadId, "error", item.string("message"), obj)
110+
}
111+
108112
itemType in TOOL_ITEMS && type == "item.started" -> {
109113
AgentEvent.AssistantMessage(
110114
threadId,
@@ -163,7 +167,13 @@ object CodexEvents {
163167
return (input ?: 0) + (output ?: 0)
164168
}
165169

166-
private fun JsonObject.errorMessage(): String? = string("message") ?: this["error"]?.asObject()?.string("message")
170+
private fun JsonObject.errorMessage(): String? = (string("message") ?: this["error"]?.asObject()?.string("message"))?.unwrapped()
171+
172+
private fun String.unwrapped(): String =
173+
runCatching { json.parseToJsonElement(this).jsonObject }
174+
.getOrNull()
175+
?.errorMessage()
176+
?: this
167177

168178
private fun JsonObject.intOrNull(key: String): Int? = this[key]?.intOrNull()
169179
}

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/CodexProvider.kt‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.dk.zopf.runtime
22

33
import com.dk.zopf.model.AgentProviderId
4+
import java.nio.file.Files
5+
import java.nio.file.Path
6+
import kotlin.io.path.writeText
47

58
object CodexProvider : AgentProvider {
69
override val id = AgentProviderId.CODEX
@@ -30,10 +33,20 @@ object CodexProvider : AgentProvider {
3033
add("exec")
3134
add("--json")
3235
add("--skip-git-repo-check")
36+
invocation.jsonSchema?.let {
37+
add("--output-schema")
38+
add(schemaFile(it).toString())
39+
}
3340
add(invocation.prompt)
3441
}
3542

43+
internal fun schemaFile(schema: String): Path =
44+
Files.createTempFile("zopf-schema-", ".json").also {
45+
it.writeText(schema)
46+
it.toFile().deleteOnExit()
47+
}
48+
3649
override fun parse(line: String): AgentEvent? = CodexEvents.parse(line)
3750

38-
override fun terminalArgs(sessionId: String?): List<String> = emptyList()
51+
override fun terminalArgs(sessionId: String?): List<String> = sessionId?.let { listOf("resume", it) }.orEmpty()
3952
}

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/NodeExecutor.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class ProcessNodeExecutor(
123123
run.live = AgentLive(session)
124124
run.sessionId = session.sessionId
125125
run.command = session.command
126+
run.model = invocation.model
126127
run.status = RunStatus.STARTING
127128
if (sandbox != null) run.notice("Sandbox: ${sandbox.cliValue}")
128129

@@ -146,6 +147,7 @@ class ProcessNodeExecutor(
146147
},
147148
awaitExit = session::awaitExit,
148149
)
150+
if (invocation.jsonSchema != null) run.takeFieldsFromJsonResult()
149151
if (session.stderrText.isNotBlank() && exit != 0) {
150152
run.notice(session.stderrText, isWarning = true)
151153
}

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/NodeRun.kt‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import androidx.compose.runtime.setValue
88
import com.dk.zopf.model.AgentProviderId
99
import com.dk.zopf.model.NodeType
1010
import kotlinx.coroutines.CompletableDeferred
11+
import kotlinx.serialization.json.Json
12+
import kotlinx.serialization.json.jsonObject
1113
import java.nio.file.Path
1214
import java.time.Duration
1315
import java.time.Instant
@@ -209,8 +211,8 @@ class NodeRun(
209211

210212
when (event) {
211213
is AgentEvent.SystemInit -> {
212-
model = event.model
213-
notice("Session started · ${event.model ?: "default model"} · ${event.cwd ?: cwd}")
214+
model = event.model ?: model
215+
notice("Session started · ${model ?: "default model"} · ${event.cwd ?: cwd}")
214216
}
215217

216218
is AgentEvent.TextDelta -> appendDelta(event.text, event.isThinking)
@@ -248,16 +250,18 @@ class NodeRun(
248250
outputBuffer.append(it)
249251
}
250252
if (event.fields.isNotEmpty()) outputFields = event.fields
251-
add(
252-
ConsoleEntry.Summary(
253-
key = nextKey,
254-
text = event.text,
255-
costUsd = event.costUsd,
256-
durationMs = event.durationMs,
257-
isError = event.isError,
258-
tokens = event.tokens,
259-
),
260-
)
253+
if (!repeatsLastSummary(event)) {
254+
add(
255+
ConsoleEntry.Summary(
256+
key = nextKey,
257+
text = event.text,
258+
costUsd = event.costUsd,
259+
durationMs = event.durationMs,
260+
isError = event.isError,
261+
tokens = event.tokens,
262+
),
263+
)
264+
}
261265

262266
status =
263267
when {
@@ -268,8 +272,14 @@ class NodeRun(
268272
}
269273

270274
is AgentEvent.Notice ->
271-
if (event.kind == "rate_limit" && event.detail != "allowed") {
272-
notice("Rate limit: ${event.detail}", isWarning = true)
275+
when {
276+
event.kind == "rate_limit" && event.detail != "allowed" ->
277+
notice("Rate limit: ${event.detail}", isWarning = true)
278+
279+
event.kind == "error" ->
280+
event.detail?.takeIf { it.isNotBlank() }?.let { notice(it, isWarning = true) }
281+
282+
else -> Unit
273283
}
274284

275285
is AgentEvent.NonJson -> notice(event.line, isWarning = true)
@@ -332,6 +342,20 @@ class NodeRun(
332342
)
333343
}
334344

345+
@Synchronized
346+
internal fun takeFieldsFromJsonResult() {
347+
if (outputFields.isNotEmpty()) return
348+
val answer = outputBuffer.toString().trim()
349+
val obj = runCatching { Json.parseToJsonElement(answer).jsonObject }.getOrNull() ?: return
350+
351+
outputFields = obj.toFields()
352+
}
353+
354+
private fun repeatsLastSummary(event: AgentEvent.Result): Boolean {
355+
val last = entries.lastOrNull() as? ConsoleEntry.Summary ?: return false
356+
return last.isError && event.isError && last.text == event.text
357+
}
358+
335359
@Synchronized
336360
fun notice(
337361
text: String,

‎core/src/commonTest/kotlin/com/dk/zopf/model/WorkflowModelTest.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ class WorkflowEditsTest {
720720
}
721721

722722
@Test
723-
fun `the declared fields become a strict JSON schema, and only the required ones are required`() {
723+
fun `an optional field is declared nullable, since a partial required list is not a strict schema`() {
724724
val schema =
725725
listOf(
726726
SchemaField("severity", description = "low, medium or high"),
@@ -729,7 +729,7 @@ class WorkflowEditsTest {
729729

730730
assertEquals(
731731
"""{"type":"object","properties":{"severity":{"type":"string","description":"low, medium or high"},""" +
732-
""""notes":{"type":"array"}},"required":["severity"],"additionalProperties":false}""",
732+
""""notes":{"type":["array","null"]}},"required":["severity","notes"],"additionalProperties":false}""",
733733
schema.toString(),
734734
)
735735
}

‎core/src/commonTest/kotlin/com/dk/zopf/runtime/AgentNodeTest.kt‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,28 @@ class CodexEventsTest {
244244
assertTrue(assertIs<AgentEvent.Result>(error).isError)
245245
}
246246

247+
@Test
248+
fun `an item that names its type the older way still parses`() {
249+
val event = CodexEvents.parse("""{"type":"item.completed","item":{"id":"i","item_type":"agent_message","text":"done"}}""")
250+
assertEquals("done", assertIs<AgentEvent.AssistantMessage>(event).text)
251+
}
252+
253+
@Test
254+
fun `an error the CLI reports mid-turn is a warning rather than the end of the node`() {
255+
val event = CodexEvents.parse("""{"type":"item.completed","item":{"id":"i","type":"error","message":"Model metadata not found."}}""")
256+
val notice = assertIs<AgentEvent.Notice>(event)
257+
258+
assertEquals("error", notice.kind)
259+
assertEquals("Model metadata not found.", notice.detail)
260+
}
261+
262+
@Test
263+
fun `an error message that is itself JSON is unwrapped to the sentence inside it`() {
264+
val line = """{"type":"turn.failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"message\":\"That model is not supported on this account.\"}}"}}"""
265+
266+
assertEquals("That model is not supported on this account.", assertIs<AgentEvent.Result>(CodexEvents.parse(line)).text)
267+
}
268+
247269
@Test
248270
fun `an item type this version has never seen survives rather than throwing`() {
249271
val event = CodexEvents.parse("""{"type":"item.completed","item":{"id":"i","item_type":"invented_later"}}""")
@@ -330,13 +352,80 @@ class NodeRunTest {
330352

331353
assertEquals(RunStatus.RUNNING, run.status)
332354
assertFalse(run.canFollowUp)
333-
assertFalse(run.canTakeOver, "an exec session cannot be resumed, so there is nothing to hand over")
355+
assertTrue(run.canTakeOver, "the thread id codex reported is enough to reopen it in a terminal")
334356

335357
assertEquals("It printed `hello`.", run.output().result)
336358
assertNull(run.costUsd)
337359
assertEquals(2634, run.tokens)
338360
}
339361

362+
@Test
363+
fun `a codex answer that fills a schema becomes fields, and stays the result`() {
364+
val line = """{"type":"item.completed","item":{"id":"i","type":"agent_message","text":"{\"severity\":\"low\",\"count\":2}"}}"""
365+
val run =
366+
NodeRun("r", "demo", "ask", "Ask", NodeType.AGENT, Paths.get("/tmp"), provider = AgentProviderId.CODEX)
367+
.apply {
368+
CodexEvents.parseAll(line).forEach(::consume)
369+
takeFieldsFromJsonResult()
370+
}
371+
372+
val output = run.output()
373+
374+
assertEquals("low", output.field("severity"))
375+
assertEquals("2", output.field("count"))
376+
assertEquals("""{"severity":"low","count":2}""", output.result)
377+
}
378+
379+
@Test
380+
fun `an answer in prose is left as prose, however the node was asked`() {
381+
val run =
382+
NodeRun("r", "demo", "ask", "Ask", NodeType.AGENT, Paths.get("/tmp"), provider = AgentProviderId.CODEX)
383+
.apply {
384+
CodexEvents
385+
.parseAll("""{"type":"item.completed","item":{"id":"i","type":"agent_message","text":"Nothing looked wrong."}}""")
386+
.forEach(::consume)
387+
takeFieldsFromJsonResult()
388+
}
389+
390+
assertEquals(emptyMap(), run.output().extras)
391+
assertEquals("Nothing looked wrong.", run.output().result)
392+
}
393+
394+
@Test
395+
fun `a codex failure that arrives as both an error and a failed turn is reported once`() {
396+
val stream =
397+
"""
398+
{"type":"error","message":"That model is not supported on this account."}
399+
{"type":"turn.failed","error":{"message":"That model is not supported on this account."}}
400+
""".trimIndent()
401+
val run =
402+
NodeRun("r", "demo", "ask", "Ask", NodeType.AGENT, Paths.get("/tmp"), provider = AgentProviderId.CODEX)
403+
.apply { CodexEvents.parseAll(stream).forEach(::consume) }
404+
405+
val summary = run.entries.filterIsInstance<ConsoleEntry.Summary>().single()
406+
407+
assertEquals("That model is not supported on this account.", summary.text)
408+
assertTrue(summary.isError)
409+
assertEquals(RunStatus.FAILED, run.status)
410+
}
411+
412+
@Test
413+
fun `an error codex reports mid-turn shows as a warning and leaves the node running`() {
414+
val run =
415+
NodeRun("r", "demo", "ask", "Ask", NodeType.AGENT, Paths.get("/tmp"), provider = AgentProviderId.CODEX)
416+
.apply {
417+
CodexEvents
418+
.parseAll("""{"type":"item.completed","item":{"id":"i","type":"error","message":"Model metadata not found."}}""")
419+
.forEach(::consume)
420+
}
421+
422+
val notice = run.entries.filterIsInstance<ConsoleEntry.Notice>().single()
423+
424+
assertEquals("Model metadata not found.", notice.text)
425+
assertTrue(notice.isWarning)
426+
assertEquals(RunStatus.RUNNING, run.status)
427+
}
428+
340429
@Test
341430
fun `a dsh run has no stream, so its stdout is the answer and the whole answer`() {
342431
val text = "Both tests pass.\n\nThe flake was the clock, not the parser.\n"

0 commit comments

Comments
 (0)