Skip to content

fix(agents): never answer a new prompt with the previous turn's answer - #189

Merged
howdeploy merged 2 commits into
howdeploy:mainfrom
BIackFIame:pr185/orch-turn-answer
Nov 1, 2026
Merged

howdeploy merged 2 commits into
howdeploy:mainfrom
BIackFIame:pr185/orch-turn-answer

Conversation

@BIackFIame

@BIackFIame BIackFIame commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #160 as a small, independent PR on 1.8.5 (it touches only what 1.8.5 does not already cover). It does not depend on the other parts.

1.8.5 already makes wait_for_agent return idle only after a turn that started after the latest prompt (turnProgress, promptTurnMark), and clears the stored answer when a turn starts. Two gaps remain around that.

Problem 1: the previous answer answers the new prompt

The stored answer (Codex Stop hook, OpenCode plugin) is cleared only when the next turn reports its start. Between send_to_agent and that start, the old answer is still returned:

Before:

  1. A subagent finishes part A; get_agent_result returns answer: "part A done".
  2. The orchestrator sends "do part B". Its turn has not reported a start yet (the CLI is slow to start it, or its start hook was lost).
  3. get_agent_result returns answer: "part A done", and wait_for_agent, which ends quiet when no turn start follows, returns the same answer. The orchestrator takes part A's answer as part B's result.

After: submitting a prompt clears the stored answer. Until part B's turn reports its answer, both calls return no answer (the screen output is still there), then they return part B's answer.

Problem 2: a turn whose start hook was lost is ignored completely

RuntimeGateway dropped every non-start event with a new turn id once a turn id was active. Each hook runs on its own connection with its own timeout, so if a turn's UserPromptSubmit is lost, all of that turn's later events (PostToolUse, Stop, and the final answer the Stop carries) were dropped as "out of order". The card kept the previous turn's status, and the answer was never recorded.

After: the gateway tracks whether a turn is open. After an idle, an unseen turn id opens the next turn. During an open turn, or for an id already seen (a late event of an earlier turn), the event is still dropped, exactly as before.

Change

  • TerminalManager: the three places that set promptTurnMark for a submitted prompt still set it before the write; the stored answer is deleted (clearSubmittedPromptAnswer) only after the submit was written. A failed write, a failed Enter write or a controller veto before Enter keeps the last answer, since nothing new was submitted.
  • RuntimeGateway: a turnOpen flag on the lease; non-start events with an unseen id are accepted only between turns. An id rejected because another turn was running is remembered as seen, so its late Stop after that turn ends stays stale.

Two files in src/, about 40 changed lines.

Why this and not less

Clearing the answer where the prompt's turn mark is already set reuses the maintainer's own notion of "the prompt's turn" and touches no reader: answer(), waitFor and result stay as they are. Filtering in answer() by turn count instead would hide a correct answer when the CLI handles a queued prompt inside the same busy period (OpenCode does that), so the simpler "clear on submit" is also the safer one. The gateway fix keeps the existing stale-event protection (seen ids, late starts of older turns) and only stops treating "start never arrived" as "stale".

What changes for existing workflows

  • An answer is still kept until a prompt is submitted or the next turn starts; an answer reported as the subagent exits still counts.
  • Input the person types into the card by hand is unchanged (only delivered prompts set the turn mark, as before).
  • No new field, no new refusal, no change to statuses other than the lost-start case, which now follows the turn's own events.

Validation

  • New test in tests/opencode-result.test.mjs: after part A's answer, send_to_agent part B; get_agent_result must have no answer and wait_for_agent must end quiet without one; part B's answer is then returned. On f3eee29 it fails (actual: { text: 'part A done' }, expected: undefined).
  • New test in tests/agent-runtime-gateway.test.mjs: with turn 2's UserPromptSubmit missing, its PostToolUse and Stop (and a Stop-only turn 3) must be delivered, while an event of another turn during turn 1 and late events of turn 1 stay dropped. On f3eee29 it fails (actual: 'idle', expected: 'working').
  • New test in tests/agent-runtime-gateway.test.mjs: UserPromptSubmit(B), PostToolUse(A), Stop(B), Stop(A) must deliver only B's events. Without remembering the rejected id, A's late Stop was delivered as a new turn.
  • New tests in tests/opencode-result.test.mjs and tests/prompt-delivery.test.mjs: a prompt whose write fails (OpenCode), whose Enter write fails or whose submit is vetoed (Codex staged path) keeps the previous answer; a submitted one clears it. Before the change all three variants lost the answer.
  • npm run build (typecheck), npm run audit:secrets, the eager renderer bundle check and the full test suite pass. Full suite: 2656 tests, 2650 pass, 0 fail, 6 skipped. The renderer is untouched (eager bundle unchanged against the other branches; renderer-bundle-splitting passes).

A subagent's stored answer was cleared only when a new turn reported its
start. Between a submitted prompt and that start (a slow or lost start
hook), get_agent_result, and wait_for_agent once it ended "quiet",
returned the previous turn's answer as if it answered the new prompt.
Submitting a prompt now clears the stored answer, where the prompt's turn
mark is set.

The runtime gateway dropped every non-start event with a new turn id once
a turn id was active. Each hook runs on its own connection and can time
out, so when a turn's start hook was lost the whole turn, its Stop and
final answer included, was ignored and the card kept the previous turn's
status. The gateway now tracks whether a turn is open: after an idle, an
unseen turn id opens the next turn; during an open turn, or for an id
already seen, the event is still dropped as out of order.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T10:45:52.828418Z ed44aae PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed44aae1a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +499 to +500
if (lease.activeTurnId && (lease.turnOpen || lease.seenTurnIds.has(message.turnId))) return null;
lease.activeTurnId = message.turnId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep rejected turn IDs ineligible for idle-time recovery

When an older turn's start hook was lost, rejecting one of its events here does not remember its ID. The sequence UserPromptSubmit(B) → PostToolUse(A) → Stop(B) → Stop(A) therefore drops A's tool event while B runs, but accepts A's late Stop as a new turn once B is idle. If both Stops carry results, the recordAnswer call in src/main/index.ts replaces B's correct answer with A's stale one, so get_agent_result returns the previous turn's answer again. Preserve the conflicting-ID rejection across the idle transition rather than treating that ID as unseen.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in a777f7c. An event of another turn that arrives while a turn is open is now remembered as seen before it is dropped, so that turn's late Stop after the running turn goes idle stays stale instead of opening a new turn. Test: "RuntimeGateway: an earlier turn rejected while the next one ran cannot replace it after that turn ends" in tests/agent-runtime-gateway.test.mjs (UserPromptSubmit(B), PostToolUse(A), Stop(B), Stop(A); only B's events are delivered). It failed before the fix with A's Stop delivered.

Comment thread src/main/services/TerminalManager.ts Outdated
Comment on lines 1172 to 1174
if (prompt.submit) markSubmittedPrompt(session, session.turnStarts ?? 0);
if (!this.inputChecked(id, data + (prompt.submit ? submitKey! : ""), "automation")) {
prompt.receipt.stage = "not-written";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain the saved answer when the submit write fails

If an OpenCode submit's PTY write fails, this call has already deleted the saved answer even though the following failure path reports a not-written receipt. Consequently, a failed send_to_agent erases the last completed answer from get_agent_result, despite no new prompt being delivered. The staged path likewise clears the answer before beforeSubmit can veto submission or the Enter write can fail. Clear the answer only after a successful submit write, retaining the pre-write turn mark, or restore it on these failure paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in a777f7c. The prompt's turn mark is still set before the write, but the stored answer is now cleared only after the submit was written. A failed write (not-written receipt), a failed Enter write, or a beforeSubmit veto in the staged path keeps the last answer. Tests: "a prompt that was not written keeps the previous turn's answer" in tests/opencode-result.test.mjs, and "a staged prompt clears the previous answer only once its Enter is written" in tests/prompt-delivery.test.mjs (veto, Enter write failure, success). Both failed before the fix.

…swer (review)

An event of another turn that arrived while a turn ran was dropped but
not remembered, so that turn's late Stop, arriving after the running turn
went idle, opened a new turn and its result replaced the correct answer.
Such a turn id is now remembered as seen and stays stale.

The stored answer was cleared before the prompt's submit was written. A
failed write, a failed Enter write, or a controller veto before Enter
delivered nothing yet erased the last answer. The answer is now cleared
only after the submit is written; the prompt's turn mark is still set
before the write.
@howdeploy
howdeploy merged commit 45ad570 into howdeploy:main Nov 1, 2026
7 checks passed
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.

2 participants