Skip to content

Commit 07fd0c3

Browse files
committed
test dsh, add smoke test
1 parent ac6b3b2 commit 07fd0c3

7 files changed

Lines changed: 109 additions & 28 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: provider-smoke
2+
description: |-
3+
Run one turn through every provider at once, to check the AgentProvider seam rather than any model.
4+
5+
This is the workflow to run after touching AgentProvider, a provider's argv or its event parsing,
6+
and after any of the three CLIs ships a release. Each arm builds a different command and parses a
7+
different output shape: claude and codex stream JSON, dsh prints plain text. Asking each one to
8+
name itself means the gate shows three different answers, so an arm wired to the wrong CLI is
9+
visible rather than merely green.
10+
11+
It fans out so the arms share the concurrency semaphore, and joins on a gate because a gate reads
12+
the results without a shell touching them. Unlike `smoke`, this one costs money, on three vendors,
13+
and needs all three CLIs installed and signed in.
14+
repos:
15+
- id: self
16+
path: ..
17+
defaults:
18+
repo: self
19+
nodes:
20+
- id: reachable
21+
type: shell
22+
title: Which CLIs are on the login PATH?
23+
command: for c in claude codex dsh; do printf '%s = ' "$c"; command -v "$c" >/dev/null 2>&1 && "$c" --version 2>&1 | head -1 || echo "not on PATH"; done
24+
- id: viaClaude
25+
type: agent
26+
provider: claude
27+
title: One turn through claude
28+
timeoutSeconds: 300
29+
prompt: Reply with exactly one short sentence naming the model you are. Do not read or write any files.
30+
- id: viaCodex
31+
type: agent
32+
provider: codex
33+
title: One turn through codex
34+
timeoutSeconds: 300
35+
prompt: Reply with exactly one short sentence naming the model you are. Do not read or write any files.
36+
- id: viaDsh
37+
type: agent
38+
provider: dsh
39+
title: One turn through dsh
40+
timeoutSeconds: 300
41+
prompt: Reply with exactly one short sentence naming the model you are. Do not read or write any files.
42+
- id: compare
43+
type: gate
44+
title: Did all three answer, each as itself?
45+
prompt: |-
46+
${reachable.result}
47+
48+
claude: ${viaClaude.result}
49+
50+
codex: ${viaCodex.result}
51+
52+
dsh: ${viaDsh.result}
53+
edges:
54+
- from: reachable
55+
to: viaClaude
56+
- from: reachable
57+
to: viaCodex
58+
- from: reachable
59+
to: viaDsh
60+
- from: viaClaude
61+
to: compare
62+
- from: viaCodex
63+
to: compare
64+
- from: viaDsh
65+
to: compare

‎CLAUDE.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,12 @@ Gradle build scripts, which are not Kotlin source sets. Explanation belongs in t
283283
test name long enough to say what it asserts (`everyWorkflowInTheSkillDocsIsWrittenTheWayTheEditorWouldWriteIt`).
284284
A comment restating what's here is a second copy that drifts.
285285

286+
Everything that is not a Kotlin source set may comment: the Gradle scripts, the connector scripts,
287+
`main.swift`, the shell scripts. Comments there earn their place or go. One lowercase fragment on the
288+
line someone would otherwise break, like `// non-jvm dirs read by unit tests so tests don't go stale`.
289+
Not a sentence, not a restatement of the line under it, and never a second copy of a paragraph from
290+
this file. The older scripts predate this and still carry prose; trim them as you touch them.
291+
286292
User-facing strings are full sentences that say what to do next, not error codes —
287293
`"Repo \"app\" isn't at ~/dev/app any more"`, not `"invalid repo"`. That is a rule against error
288294
codes, not a licence to explain: one sentence, carrying only what the screen isn't already showing.

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<b>A macOS app for running agent workflows as a graph.</b>
99
</p>
1010

11-
zopf allows you to connect several claude, codex or deepseek sessions into a graph and run them together instead of one
11+
zopf allows you to connect several claude, codex or DeepSeek Harness sessions into a graph and run them together instead of one
1212
terminal session at a time. You can also span this across multiple directories and invoke skills and other context on
1313
the side.
1414

@@ -47,6 +47,11 @@ You need **macOS on Apple Silicon** and one of these installed and signed in:
4747
> While zopf supports claude code, codex and dsh so far, I've only extensively tested claude code as it is my daily
4848
driver.
4949

50+
`claude` and `codex` installs can just follow the official instructions. `dsh` needs one extra step: install it
51+
globally, because zopf looks for a `dsh` binary on your login PATH. After
52+
that, follow [their guide](https://deepseek-harness.github.io/deepseek-harness/en/guide/providers) to add a provider,
53+
since dsh ships no model of its own. Note that dsh itself is still a 0.1.x developer preview.
54+
5055
### The App
5156

5257
Download `zopf-<version>.dmg` from the [latest release](../../releases/latest) and drag it to Applications. On first

‎core/build.gradle.kts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,16 @@ plugins {
44
alias(libs.plugins.ktlint)
55
}
66

7-
// One version for every surface. Generated rather than checked in so a tagged release and a local
8-
// build can never disagree with the jar they came from; read back through store/BuildInfo.kt.
97
val zopfVersion = (findProperty("packageVersion") ?: findProperty("zopfVersion") ?: "0.0.0").toString()
108

11-
// The workflow file format, bumped by hand when a change alters what an existing file means. Not
12-
// the app's version and never overridden by the release tag: the two move independently.
139
val workflowVersion = (findProperty("workflowVersion") ?: "1").toString()
1410

1511
val writeVersion =
1612
tasks.register("writeVersion") {
1713
description = "Writes the build's version and the workflow format version into resources read back by store/BuildInfo.kt."
18-
// Locals, not the script's properties: the configuration cache can't serialize a reference
19-
// back into the build script from a task action.
14+
// locals: config cache can't serialize script props into task actions
2015
val version = zopfVersion
2116
val format = workflowVersion
22-
// A directory, because this is wired in as a resources srcDir rather than a single file.
2317
val output = layout.buildDirectory.dir("generated/zopf")
2418
inputs.property("version", version)
2519
inputs.property("workflowVersion", format)
@@ -38,9 +32,6 @@ kotlin {
3832

3933
sourceSets {
4034
commonMain.dependencies {
41-
// Snapshot state, not the compiler: NodeRun and RunRegistry are observable by a
42-
// composition when there is one, and ordinary objects when there isn't. It is api()
43-
// because those objects hand a SnapshotStateList back to whoever reads them.
4435
api(libs.compose.runtime)
4536

4637
implementation(libs.kotlinx.coroutinesCore)
@@ -50,9 +41,20 @@ kotlin {
5041
commonTest.dependencies {
5142
implementation(libs.kotlin.test)
5243
}
53-
// Fixtures :shared's tests compile too — see ExampleWorkflow.kt for why.
5444
commonTest { kotlin.srcDir("src/testFixtures/kotlin") }
5545

5646
commonMain { resources.srcDir(writeVersion) }
5747
}
5848
}
49+
50+
// non-jvm dirs read by unit tests so tests don't go stale
51+
tasks.named<Test>("jvmTest") {
52+
inputs
53+
.dir(rootProject.layout.projectDirectory.dir(".zopf"))
54+
.withPropertyName("dogfoodWorkspace")
55+
.withPathSensitivity(PathSensitivity.RELATIVE)
56+
inputs
57+
.dir(rootProject.layout.projectDirectory.dir("plugins/zopf/skills"))
58+
.withPropertyName("skillDocs")
59+
.withPathSensitivity(PathSensitivity.RELATIVE)
60+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ private fun WorkflowNode.providerIssues(
306306
provider.ignoredFields(this).takeIf { it.isNotEmpty() }?.let {
307307
issues +=
308308
WorkflowIssue(
309-
"$displayTitle runs ${provider.label}, which has no ${it.joinToString()}",
309+
"$displayTitle runs ${provider.label}, which ignores ${it.joinToString()}",
310310
id,
311311
WorkflowIssue.Severity.WARNING,
312312
)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ class AgentCapabilitiesTest {
247247
}
248248

249249
@Test
250-
fun `a CLI that takes a model suggests some, and dsh suggests none because it takes none`() {
250+
fun `a CLI that takes a model suggests some, and dsh suggests none because its model is the profile's`() {
251251
assertContains(AgentProviderId.CLAUDE.modelOptions, "opus")
252252
assertTrue(AgentProviderId.CODEX.modelOptions.isNotEmpty())
253253
assertEquals(emptyList(), AgentProviderId.DSH.modelOptions)

‎plugins/zopf/skills/zopf-workflows/references/schema.md‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ nothing until `zopf validate` reports it as a stray key.
3232

3333
### version
3434

35-
Leave it out. Absent means the current format, and the editor never adds the key. An older number is
36-
carried forward on load; a newer one is an error and the run is refused.
35+
Leave it out. Absent means the current format, and the editor never adds the key. An older number is carried forward on
36+
load; a newer one is an error and the run is refused.
3737

3838
## repos
3939

@@ -79,7 +79,7 @@ node → workflow `defaults` → workspace `defaults` → the app's Settings.
7979
| `alsoRead` | list of string | `agent` | Extra declared repo ids the node may access (`--add-dir`). Grants write access too, not just read. |
8080
| `allowedTools` | list of string | `agent` | What runs without being asked: `Read`, `Grep`, `Bash(git push *)`. **Grants, never restricts**, see below. **Not interpolated.** |
8181
| `skills` | list of string | `agent` | Skill **names**, not paths. Resolved against the workflow's repos, the workspace `skills/`, and `~/.claude/skills`. |
82-
| `model` | string | `agent` | Overrides `defaults.model`. Not read by `dsh`, which has no flag for one. |
82+
| `model` | string | `agent` | Overrides `defaults.model`. Not read by `dsh`; its model lives in the profile. |
8383
| `permissionMode` | enum | `agent` (claude) | `acceptEdits` \| `auto` \| `bypassPermissions` \| `manual` \| `dontAsk` \| `plan`. |
8484
| `sandbox` | enum | `agent` (codex) | `read-only` \| `workspace-write` \| `danger-full-access`. Chosen before launch; codex cannot be asked mid-turn. |
8585
| `schema` | list of field | `agent` | Declares the answer's shape. Each name becomes `${id.name}`. Absent means prose, as before. See [schema](#schema). |
@@ -95,17 +95,18 @@ node → workflow `defaults` → workspace `defaults` → the app's Settings.
9595
It lists what runs **without stopping to ask**. A node with
9696
`allowedTools: [Read, Glob, Grep]` still has `Bash`, `Write` and `Edit`, and will use them.
9797

98-
What it doesn't name is left to the CLI: read-only commands (`grep`, `find`, `git log`) run, the rest needs an
99-
answer. Inline approval answers in the app; `zopf run` has no approver, so headless the answer is no. Name what the
100-
node needs, patterns included, or it works in the window and fails from cron. `Bash(git push *)` is a real entry.
98+
What it doesn't name is left to the CLI: read-only commands (`grep`, `find`, `git log`) run, the rest needs an answer.
99+
Inline approval answers in the app; `zopf run` has no approver, so headless the answer is no. Name what the node needs,
100+
patterns included, or it works in the window and fails from cron. `Bash(git push *)` is a real entry.
101101

102102
### Model names belong to a CLI
103103

104104
`opus`, `sonnet` and `haiku` are Claude Code's. codex takes its own names, and zopf ships no list of them — write one
105-
into the node's `model:` or leave it out and let codex use whatever it is configured for. `dsh` takes none at all: its
106-
headless profile has no `--model`, so a `model:` on a dsh node is a validation warning. This is enforced rather than
107-
merely documented: `defaults.model` is read only by nodes running the *workflow's* default CLI, and the app's default
108-
model only by nodes running the *machine's* default CLI, so `model: opus` in a workflow's defaults never reaches
105+
into the node's `model:` or leave it out and let codex use whatever it is configured for. `dsh` chooses its model in the
106+
profile rather than on the command line, because everything in it is a plugin and the model is one of them. There is no
107+
`--model` for zopf to pass, so a `model:` on a dsh node is a validation warning. This is enforced rather than merely
108+
documented: `defaults.model` is read only by nodes running the *workflow's* default CLI, and the app's default model
109+
only by nodes running the *machine's* default CLI, so `model: opus` in a workflow's defaults never reaches
109110
`codex --model opus`. A node's own `model:` is always honoured, because it was written next to its `provider:`.
110111

111112
### Which CLI, and what it can do
@@ -117,7 +118,9 @@ and take-over both work on a codex node. `alsoRead:` is ignored there, because c
117118
its own version of the flag hands out writes instead.
118119

119120
`provider: dsh` runs DeepSeek Harness's headless profile, which is one turn and nothing else: `prompt:`, `repo:` and
120-
`timeoutSeconds:` are the only node keys it reads, and its whole stdout becomes `${id.result}`.
121+
`timeoutSeconds:` are the only node keys it reads, and its whole stdout becomes `${id.result}`. Which vendor answers is
122+
the profile's decision: dsh is provider-agnostic, so a dsh node may be talking to DeepSeek, Anthropic, OpenAI or
123+
anything else it has a model plugin for.
121124

122125
A field the chosen CLI has no version of is left out of the command and `zopf validate` warns, naming it — so `skills:`
123126
on a codex node is a warning rather than a silent no-op.
@@ -294,8 +297,8 @@ leaked value is a token to rotate.
294297
| a duplicated schema field | `<node> declares the output field "x" more than once` |
295298
| a schema field named `result` | `<node> declares an output field named "x", which ${id.x} already means` |
296299

297-
An error stops a run before it starts. `zopf run` prints them and exits 3 without running a node, and the app
298-
says what to fix instead of starting.
300+
An error stops a run before it starts. `zopf run` prints them and exits 3 without running a node, and the app says what
301+
to fix instead of starting.
299302

300303
**Warnings** (exit 0, but read them):
301304

@@ -306,7 +309,7 @@ says what to fix instead of starting.
306309
| `on: failure` from a gate/branch/input | `<node> is a gate, which never fails, so the edge to <y> can never be taken` |
307310
| both `prompt` and `promptFile` | `<node> sends <file>, so its inline prompt is ignored` |
308311
| a `default` outside `choices` | `<node> offers a, b, so its default "c" can't be picked` |
309-
| a field the node's CLI has no version of | `<node> runs codex, which has no permissionMode, skills` |
312+
| a field the node's CLI has no version of | `<node> runs codex, which ignores permissionMode, skills` |
310313
| a CLI that isn't installed | `<node> runs codex, which isn't on your PATH. The node will fail when it starts` |
311314
| an unknown skill name | `<node> uses the "x" skill, which isn't in this workflow's repos, the workspace or ~/.claude/skills` |
312315
| a schema field `${...}` can't name | `<node> declares the output field "x", which ${id.…} can't name` |

0 commit comments

Comments
 (0)