Skip to content

fix: /product and /loop must start a turn, not just return text - #47

Merged
linhdmn merged 1 commit into
mainfrom
fix/product-command-starts-a-turn
Oct 7, 2026
Merged

linhdmn merged 1 commit into
mainfrom
fix/product-command-starts-a-turn

Conversation

@linhdmn

@linhdmn linhdmn commented Oct 7, 2026

Copy link
Copy Markdown
Member

Summary

/product and /loop in the composer produced a command row and no turn at all. They now start one.

The measurement

Driving the real web UI headless on harness 0.2.0-rc.2 (2026-10-07), typing /product … into the composer and pressing Enter gave this session log — the whole of it:

session
permission/preset
sandbox/mode
approval/policy
command/run    ← the command ran
command/done   ← and returned success

No turn/start. No model call, no tool call, no pipeline. The command row rendered and the run never began. GET /api/state showed no run record either.

Why

src/command.ts returned the task text as a success result, on this assumption (verbatim from the old docstring):

The composer submits the returned text as the turn, so the loop policies apply to it exactly as to any typed request.

It does not. In packages/client/ui-commands/src/client/service.ts, the claimed-command path is submit: (args, …) => this.execute(session, line + args, attachments) — it calls commands.execute() and nothing else. And onSubmitSettled in packages/client/ui-conversation/src/client/input/machine.ts does this with the result:

if (ev.outcome?.text !== undefined) {
  effects.push({ type: 'notice', level: …, text: ev.outcome.text })
}

A notice. That is the entire contract for a command's returned text. (/goal works because its handler mutates goal state the host acts on — not because the composer promotes text.)

The fix

The handler now does what dsh headless does for its one task (packages/bundle/headless/src/index.ts:365):

invocation.agent.followup(createUserMessage({
  content: [{ type: 'text', text: outcome.task }],
  source: { kind: 'plugin:feature-loop', form: 'notice', summary: … },
}))

followup is the documented seam for "becomes the sole ordinary message of its own turn" and it wakes the driver.

Three deliberate choices:

  • It does not await whenIdle(). A five-phase pipeline runs for minutes; awaiting would hold the composer in its submitting phase for the whole run and lock the input bar. Queue and return is the handoff.
  • The message is attributed to plugin:feature-loop, not user. The human typed /product <goal>; the expanded task text is this plugin's construction. A user source would put words in their mouth in the durable log.
  • A refused handoff returns kind: 'error'. That is the one shape the composer keeps the draft for — returning success would silently discard the line.

Verification

Unit. The new structural assertions fail on the old src/command.ts (not ok 12) and pass on the fix. They strip comments first — the first version of this test failed on the word whenIdle() inside a comment explaining why it is not called, which is an assertion about prose. Full suite 822 pass / 0 fail / 1 skipped; tsc --noEmit clean; pre-commit scan clean.

End to end, through the real composer. Headless Chrome, plugin installed from this branch, /product … typed into the composer and submitted with Enter. The session log now reads:

command/run
agent/inbox/spliced   ← the task, queued as a turn
turn/start            ← THE ASSERTION
command/done          {"kind":"success","text":"the loop is running — the gate will ask before anything irreversible"}
step/start            {"turn":1,"step":1}
system/message
user/message          ← the task, as a real user-role message

and the run proceeded: phase=research, asks claimed and settled by the dashboard, and the agent spawning its own research subagents (subagent/descriptor, mode: "continuable").

Note

This is the second bug found by the same headless run. The first (an open dashboard tab was a watcher only 60% of the time, so the ask hung) is #46, already merged as b4c3f44. That one had to be fixed before this one was observable at all: with the watcher bug, the run stalled at its first gated tool call before the missing turn would have shown up.

The handler returned the task text as a `success` result on the assumption
that "the composer submits the returned text as the turn". Measured on harness
0.2.0-rc.2, it does not: the claimed-command path calls `commands.execute()`,
and `onSubmitSettled` renders a success result's `text` as an inline notice.
A live `/product` in the web UI produced `command/run` + `command/done` and
no `turn/start` at all — a command row and nothing else, with the 0→1 pipeline
never started. (`/goal` works because its handler mutates goal state the host
acts on, not because the composer promotes the text.)

The handler now does what `dsh headless` does for its one task:
`agent.followup(createUserMessage(...))`, which queues the task as the sole
ordinary message of its own turn and wakes the driver. It deliberately does NOT
await `whenIdle()` — a five-phase pipeline runs for minutes, and the composer's
submit transaction would hold the input bar in its submitting phase for all of
it. Queue and return is the whole handoff.

The message is attributed to `plugin:feature-loop`, not to `user`: the human
typed `/product <goal>`, and the expanded task text is this plugin's
construction, so a `user` source would put words in their mouth in the durable
log. A refused handoff returns an `error` result, which is the one shape the
composer keeps the draft for.

Found by driving a real headless run: 21 steps, five phases, and the pipeline
only ran because the task was submitted through `session/prompt` by hand.
The unit suite passed throughout, because every assertion checked the argument
grammar and none checked that a turn begins — so two structural assertions are
added here (with comments stripped first, since an assertion that reads prose is
an assertion about the prose).
@linhdmn
linhdmn merged commit 18a94e3 into main Oct 7, 2026
3 checks passed
@linhdmn
linhdmn deleted the fix/product-command-starts-a-turn branch October 7, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant