Skip to content

fix(repl): forward the run's policy instead of re-listing the options it knows - #75

Merged
kemalelmizan merged 1 commit into
mainfrom
fix/repl-run-seams
Oct 17, 2026
Merged

kemalelmizan merged 1 commit into
mainfrom
fix/repl-run-seams

Conversation

@kemalelmizan

Copy link
Copy Markdown
Contributor

The bug

The REPL built its runAgent call field by field, and the list was incomplete:

const result = await runAgent({
  model: options.model,
  tools: options.tools,
  executor: options.executor,
  messages: next,
  maxSteps: options.maxSteps,
  decoding: options.decoding,
  selectModel: options.selectModel,
  decide: options.decide,
  stream: options.stream,
  onEvent: render,
});

steer and stopGuard were never in it. The consequence is invisible and wrong:

  • --plan-interval <n> planned nothing in the REPL.
  • --mode tasks could end a turn with the shared list still unfinished.

Both work in a one-shot run, so the flags look healthy; only the interactive path was broken. It is the same shape as a mode whose tools are wired but whose runner is not (#69): the capability exists and the host drops it on the way to the loop.

I found it while adding the planning-cost report (#74): the /steps footer asserted in the e2e never appeared, because no planning call ever happened in the REPL.

The fix

Structural, not a third field on the list:

// ReplOptions extends the loop's options, so a new seam is forwarded by default
export interface ReplOptions extends Omit<RunAgentOptions, "messages" | "onEvent" | "executor"> {
  executor: AgentExecutor & Partial<ApprovalHost>;
  // …the REPL-only options
}
const result = await runAgent({ ...options, messages: next, onEvent: render });

A new seam added to the loop now flows to the REPL automatically; dropping one has to be written down. The ReplOptions doc comment records why, and docs/architecture.md states the rule where the opt-in seams are described.

Verified

  • Two unit tests pin the seams that were missing: a steer whose text must reach the model's next prompt, and a stopGuard that must earn a second model call. Both fail against the old hand-picked list (checked by temporarily restoring it).
  • One e2e test drives the real REPL over stdin and asserts a --plan-interval 1 turn plans, records the plans, and reports planning: plans=… plan_tokens=… plan_ms=… in /steps. The driver is answer-driven — readline.question drops anything typed while no prompt is pending, so a single stdin dump only ever runs the first line.
  • pnpm build, pnpm typecheck, pnpm lint, pnpm test:coverage — green (331 passed / 5 skipped; coverage 90.3 / 84.1 / 89.3 / 90.3).

Note

The /steps footer spacing (planning: plans=… instead of planning:plans=…) is fixed here rather than in #74: the footer could not appear before this change, so the cosmetic problem had no way to be seen.

… it knows

The REPL built its `runAgent` call field by field, and the list was incomplete:
`steer` and `stopGuard` were never passed. So `--plan-interval` planned nothing
interactively and `--mode tasks` could end a turn with its list unfinished —
both worked in a one-shot run, and both did nothing in the REPL, silently. That
is the same failure shape as a mode whose tools are wired but whose runner is
not (#69): the capability exists, the host drops it on the way to the loop.

The fix is structural rather than a third field on the list. `ReplOptions` now
extends the loop's `RunAgentOptions` (minus `messages`/`onEvent`, which the REPL
owns), and the turn passes its options through wholesale:

    const result = await runAgent({ ...options, messages: next, onEvent: render });

so a new run seam is forwarded by default and omitting one has to be written
down. The `ReplOptions` doc comment says why, because the next person to add an
option to the loop should not have to rediscover this.

Two unit tests pin the seams that were missing — a `steer` whose text must reach
the model, and a `stopGuard` that must earn a second model call — and both fail
against the old hand-picked list. One e2e test drives the real REPL over stdin
(answer-driven, because `readline.question` drops anything typed while no prompt
is pending) and asserts a `--plan-interval 1` turn plans, records the plans, and
reports the cost in `/steps`.

That last assertion also needed the `/steps` footer to read `planning: plans=…`
rather than `planning:plans=…`; the footer could not appear before this fix, so
the spacing had no chance to be seen (it landed in #74).

Docs: `docs/architecture.md` states the forwarding rule where the opt-in seams
are described, `README.md` says run policy applies interactively, and `AGENTS.md`
gains the REPL paragraph next to the loop seams.

Verified: `pnpm build`, `pnpm typecheck`, `pnpm lint`, `pnpm test:coverage` all
green (coverage 90.3 / 84.1 / 89.3 / 90.3). `src/repl.ts`, `src/repl.test.ts` and
the touched docs are prettier-clean; the pre-existing drift in
`src/cli.e2e.test.ts` is left alone (#49).
@kemalelmizan
kemalelmizan merged commit 244c3d9 into main Oct 17, 2026
2 checks passed
@kemalelmizan
kemalelmizan deleted the fix/repl-run-seams branch October 17, 2026 03:24
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