Repository navigation
fix: /product and /loop must start a turn, not just return text - #47
Merged
Merged
Conversation
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).
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/productand/loopin 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:No
turn/start. No model call, no tool call, no pipeline. The command row rendered and the run never began.GET /api/stateshowed no run record either.Why
src/command.tsreturned the task text as asuccessresult, on this assumption (verbatim from the old docstring):It does not. In
packages/client/ui-commands/src/client/service.ts, the claimed-command path issubmit: (args, …) => this.execute(session, line + args, attachments)— it callscommands.execute()and nothing else. AndonSubmitSettledinpackages/client/ui-conversation/src/client/input/machine.tsdoes this with the result:A notice. That is the entire contract for a command's returned text. (
/goalworks because its handler mutates goal state the host acts on — not because the composer promotes text.)The fix
The handler now does what
dsh headlessdoes for its one task (packages/bundle/headless/src/index.ts:365):followupis the documented seam for "becomes the sole ordinary message of its own turn" and it wakes the driver.Three deliberate choices:
whenIdle(). A five-phase pipeline runs for minutes; awaiting would hold the composer in itssubmittingphase for the whole run and lock the input bar. Queue and return is the handoff.plugin:feature-loop, notuser. The human typed/product <goal>; the expanded task text is this plugin's construction. Ausersource would put words in their mouth in the durable log.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 wordwhenIdle()inside a comment explaining why it is not called, which is an assertion about prose. Full suite 822 pass / 0 fail / 1 skipped;tsc --noEmitclean; 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: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.