Conversation
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks for addressing the output-free tool loop in the shared Runtime boundary. The cap is a useful safety net and keeps explicit maxSteps authoritative. I found one normal Responses-protocol path that still bypasses the new counter.
This is a suggestion from an outside review, so please push back if the empty reasoning carrier has a different production invariant than the adapter currently expresses.
中文摘要
感谢把无输出 tool loop 的保护放在共享 Runtime 边界。当前仍有 1 个 P1:OpenAI Responses 的空 reasoning carrier 会把 stepSawThinking 置真,使完全无可见进展的重复 tool step 永远不进入新计数器。若我遗漏了 provider invariant,欢迎直接 push back。
AI-assisted review disclosure: Codex coordinated an independent reviewer lane; Astro-Han independently checked the exact head, adapter composition, reachability, and severity, and owns this review.
| stepTextPartStartOffset, | ||
| ); | ||
| } else if (event.kind === 'thinking') { | ||
| stepSawThinking = true; |
There was a problem hiding this comment.
Thanks for adding the shared loop cap. I think this unconditional flag leaves a [P1] category ① production bypass for OpenAI Responses: the model adapter emits a thinking event with text: "" at reasoning-end whenever Responses provider metadata is present. A normal textless tool step can therefore set stepSawThinking=true even though it has no visible reasoning or text, so emptyStepSignature stays undefined and identical tool-only steps never reach the new cap. The added test uses a mock with no such carrier, so it does not exercise the real Responses composition. My suggestion is to count only non-empty visible thinking here (while preserving provider metadata separately) and add a Responses reasoning-end → repeated tool-call regression. Please push back if an empty carrier is intentionally considered user-visible progress.
There was a problem hiding this comment.
Thanks — that was a real bypass. An empty Responses reasoning-end carrier is not user-visible progress.
stepSawThinking now only flips on non-empty thinking text. Provider metadata still persists separately via sawStepThinking, so the encrypted carrier still round-trips.
Added a regression that streams Responses reasoning-end with empty text + identical tool-calls and asserts the loop cap still fires (step_limit after 3 steps).
Thanks — that was a real bypass. An empty Responses
Added a regression that streams Responses |
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks for bounding repeated empty assistant steps and for adding the Responses empty-reasoning regression. One normal provider carrier still bypasses the new cap. This is a suggestion from an outside review, so please do push back if a signature-only event is intentionally treated as user-visible progress.
AI-assisted review disclosure: Codex ran an independent runtime/retry analysis lane; Astro-Han is the contributor of record for this review.
| text: event.text, | ||
| } satisfies ThinkingDeltaEvent); | ||
| } else if (event.kind === 'thinking-signature') { | ||
| stepSawThinking = true; |
There was a problem hiding this comment.
[P1] (category ① — normal provider stream path)
Thanks for preserving the signature for continuation/replay. Setting stepSawThinking for a signature-only carrier also prevents emptyStepSignature from being computed. Anthropic/Responses can emit omitted or redacted reasoning as a standalone signature with no text (the adapter and current tests explicitly preserve that path), so a model that repeats the same textless tool call plus signature can still loop without ever reaching the three-step cap. Could the signature remain persisted without counting as visible progress, and add a signature-only + repeated-identical-tool-call regression? Please feel free to push back if the provider contract guarantees every such signature corresponds to substantive progress that should reset the bound.
Agreed — a standalone signature is omitted/redacted reasoning, not user-visible progress.
Added a regression: Anthropic signature-only delta (no thinking text) + the same tool-call repeats, then the turn ends at |
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks for taking #4083 on — the loop cap is more carefully built than most attempts at this problem. Before anything else, though:
CI has never run on this branch. The head commit 7a26babf has zero check runs, so none of the verification is independently confirmed. Please rebase onto current main and push; that should get the workflows going. main has moved a long way since 08-31, and this touches ai-sdk-backend.ts, which has changed in that window.
What holds up, and it's the hard part: the empty-step signature is conservative in exactly the right places. Requiring no visible text, no thinking, at least one tool call, and an identical toolName + input means an ordinary multi-step workflow can't trip it, and gating the whole thing on maxSteps === undefined leaves every explicit budget authoritative. Distinguishing stepSawThinking (non-empty text only) from sawStepThinking (any carrier) is the detail that makes it actually work — the OpenAI Responses empty carrier at reasoning-end would otherwise reset the streak on every step and the cap would never fire. Same for treating a standalone thinking-signature as non-progress. Resetting the streak when a steer is redirected is right too.
Two things I'd like you to look at.
P2 — alternating signatures walk straight past the cap. The streak only survives while consecutive signatures are equal, so A B A B A B … — two different textless tool steps taking turns — keeps resetting to 1 and never reaches 3. That is still a runaway empty-reply loop, and it's the same user-visible symptom #4083 reports. I don't think the fix is to drop the equality requirement (consecutive different textless tool steps are ordinary agent work), but a small window would cover it: track the last N signatures and stop when the window has no distinct progress, rather than comparing only against the immediately previous one.
P2 — resolveFollowUpModeAtSubmit is now an identity function with a dead parameter.
export function resolveFollowUpModeAtSubmit(input: {
requestedMode?: FollowUpMode;
/** Retained so call sites keep compiling. */
hasActiveTurn?: boolean;
}): FollowUpMode | undefined {
if (input.requestedMode) return input.requestedMode;
return undefined;
}That is input.requestedMode with extra steps. The comment is honest about why the parameter is still there, which is the problem: a parameter kept alive to avoid touching call sites is the kind of thing that reads as meaningful to the next person. Since the function no longer decides anything, please delete it and let the call site use metadata?.followUpMode directly. hasActiveTurnAtSubmit still earns its keep — the new interrupt branch uses it — so that one stays.
One question rather than a finding. After sessions.stop(...) resolves as interrupted, the code falls through to a new root send. Does the Host guarantee the new send is admitted, or can it come back session_busy while the interrupted turn is still settling? The per-Session admission gate is FIFO with no priority lane (this is the same ground as #3713), so I want to know whether the ordering here is guaranteed or just usually fast enough. If it's the latter, it's worth a test that holds the stop's settlement open.
7a26bab to
36bd2fb
Compare
|
@Astro-Han Thanks for the careful re-review — addressed all three points and rebased onto current main so CI can run. P2 alternating signatures. Agreed that consecutive-equality alone lets P2 Stop → send / Verification: |
Plain Enter mid-turn now interrupts before a new root send so runaway empty replies can be stopped (apache#4083). When maxSteps is unset, textless tool-only steps are capped for identical streaks and for short alternating cycles in a sliding window. Empty Responses carriers and signature-only thinking no longer reset that bound. Drop the identity resolveFollowUpModeAtSubmit helper. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Route plain-Enter interrupt through createAppShellStopAction so app-shell.tsx does not grow window.maka.sessions.stop bridgePaths, and refresh the renderer architecture ledger after the token-neutral comment fold that keeps nonTriviaTokens under the ratchet. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
5df229e to
270774f
Compare
Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
me2seeks
left a comment
There was a problem hiding this comment.
Automated review (Command Code) — not an approval
The change does bound the reported loop, and the desktop interrupt is correctly ordered (it awaits the host's terminal settlement before a new root starts). But the new heuristic is broader than the problem, and it is reported as something it is not.
P2 (Should-Fix) — the empty-step window stops turns that are silent but making progress.
// packages/runtime/src/ai-sdk-turn.ts:2396-2408
const windowHasNoDistinctProgress =
recentEmptyStepSignatures.length >= EMPTY_STEP_SIGNATURE_WINDOW &&
new Set(recentEmptyStepSignatures).size < recentEmptyStepSignatures.length;
if (
consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS ||
windowHasNoDistinctProgress
) {
this.loopStopReason = 'step_limit';
this.loopStopRequested = true;
}The signature is derived from the request only, and the tool results — the actual progress — are not part of it. So any duplicate among six consecutive textless tool steps ends the turn. A normal "search, then read each hit" pattern (the same search signature recurring while every result is new) trips it, as does five distinct steps plus one benign repeat. Because the desktop passes no step budget, the cap is also the effective bound with no way to tune or disable it.
This is not hypothetical: the PR had to edit an existing fixture to work around it —
// packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts
+ /** Give each scripted `tool` step a distinct Read path. Needed when a test
+ * chains several textless tool steps: the Runtime empty-step cap (#4083)
+ * stops consecutive identical tool signatures ... */
+ distinctToolPaths?: boolean;
i.e. a previously legitimate fixture now trips the new heuristic and was changed to avoid it. Smallest sound fix: make progress evidence result-aware (a step only counts as empty when neither the request nor the result changed), and/or expose the thresholds.
P2 (Should-Fix) — the heuristic stop is persisted and rendered as a configured step limit, and marks the invocation failed.
loopStopReason = 'step_limit' (:2407) maps to the tool_step_cap_reached failure class and a transcript note whose copy reads "Reached the configured step limit…" — but no step limit is configured on this path. The user and the telemetry cannot distinguish this heuristic from a real configured cap. A distinct stop reason (or gating the note copy on whether a budget was actually configured) would keep both accurate. It is at least not recorded as a user cancel.
P2 (Should-Fix) — the desktop interrupts before the send is known to be admissible.
The new ordering runs the stop before the revision and eligibility checks, so a mid-turn send that then fails those checks (unchanged revision text, or a failed revision preparation) has already killed the active turn while sending nothing. Move the interrupt after the eligibility checks, immediately before the actual send.
P3 (Nice-to-have) — the queue follow-up lane is now unreachable from the main shell. The only producer of the queue mode is deleted and the composer only ever passes steer, so the host rejects a busy send instead of queueing it, while the queue UI (promote/reorder/delete) remains. If that is intentional, the UI and the product copy should say so.
P3 (Nice-to-have) — one clear is redundant. The clearEmptyStepProgress() call on the tool-free steer continuation is reached only when no tool calls were returned, in which case the signature is already undefined and the existing else branch has cleared it.
Review-relevant risks. Changing retry/termination policy and user-visible failure classification warrants independent human review under CONTRIBUTING.md. No security or licensing effect found.
Required conclusion.
- Optimal for the actual problem? Partially. The desktop interrupt is well placed; the runtime adds a second hidden step budget alongside the existing one, where a single default budget (or a result-aware progress check with its own stop reason) would be smaller and observably clean.
- Production code that can be deleted? The redundant clear noted above; and if the queue lane is truly gone, the unreachable next-turn branch.
- Low-quality tests to delete or replace? None to delete. Add the missing must-survive coverage: six or more distinct textless steps must continue, a text/thinking step must reset the window, and a benign repeat whose result changed must not trip it — no current test pins the survive side, so a mis-sized window within the range would pass.
- Deeper refactor required? Recommended, not blocking: one progress/budget source of truth carrying its own terminal classification.
- Ready to merge? Not as-is — the P2s above should be resolved and the must-survive test added.
- Residual risks / verification gaps: I did not run the suite. The branch is based on an older
mainin the touched renderer file, so a rebase may reveal further drift.
Approval boundary. This is automated review; it is not an approval. Per CONTRIBUTING.md, the merge decision requires an independent human review. No approve was submitted.
Make empty-step progress result-aware, stop as empty_step_loop instead of a configured step_limit, interrupt mid-turn only after send eligibility, and pin must-survive coverage for distinct/result-changing textless steps. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
|
@me2seeks Thanks for the careful review — addressed the P2s (and the redundant clear) on P2 result-aware empty-step progress. Agreed the request-only window was too broad. A textless tool step now signatures P2 distinct stop reason. Heuristic stops now use P2 interrupt after eligibility. Plain-Enter interrupt moved to immediately before the root P3 redundant clear. Removed the steer-path P3 queue lane. Left as intentional for this PR: main-shell plain Enter interrupts+roots instead of queueing; Shift+Enter still steers. Happy to follow up on product copy / UI if maintainers want the queue surface retired or re-documented. Must-survive tests added. Six+ distinct textless steps continue; visible text resets the window; same request with changing results does not trip. Existing trip cases now assert Verification: |
me2seeks
left a comment
There was a problem hiding this comment.
Follow-up (Command Code) — verified resolved
I re-checked the head after your push.
The P2 is fixed. The empty-step signature now includes the tool result alongside the name and input, so a step that repeats a request while producing new output is no longer treated as no-progress — which was my concern (a search-then-read pattern tripping the bound, and a fixture that had to be rewritten to avoid it). Watching the (toolName, input, result) triple is the result-aware shape I asked for, and the window handling was tightened as well.
Thanks. (Automated review; not an approval.)
hqhq1025
left a comment
There was a problem hiding this comment.
This head changes plain Enter during a live turn to interrupt before a root send and adds a bound for repeated textless tool-only steps without an explicit maxSteps. I found two remaining race/correctness paths (inline). The existing current-head review verifies that including tool results in the empty-step signature resolves its separate progress-detection concern; these findings do not repeat that point.
There are no visible CI checks on this head. It also currently conflicts with main in the Desktop composer/routing files, runtime turn implementation, and related ledgers/tests. I checked the diff and control flow but did not run a complete build or a real Electron/Host timing reproduction. No schema migration is involved.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| agentLoop: for (;;) { | ||
| let stepSawVisibleText = false; | ||
| let stepSawThinking = false; | ||
| await this.drainSteeringInto(input, queue); |
There was a problem hiding this comment.
[P2] Clear the empty-step streak when this top-of-loop drain injects a new steer. The only progress reset here occurs on a visible/thinking/different tool step, not when drainSteeringInto adds a user message. After two identical textless tool steps, a Shift+Enter steer arriving between the post-step drain and this drain can be injected before the next model request, yet an identical tool result then becomes the third step and immediately triggers empty_step_loop. Use the injected-message count to reset the streak and add that timing regression.
| // not kill the active turn. Host `turn.interrupt` awaits the cancelled | ||
| // turn's terminal fact before resolving, so the Session lane is free for | ||
| // the root send below. | ||
| if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false; |
There was a problem hiding this comment.
[P2] Preserve the submitting Session across the awaited stop. This captures sessionId before stopping, but the following send() reads activeIdRef.current again in app-shell-chat-actions.ts. If the user submits A's draft and navigates to B while sessions.stop(A) is pending, the root send targets B. Guard against a changed active Session after the await (or pass the captured owner to send), and test navigation during a deferred stop.
…upt session Clear the empty-step counter when a mid-loop steer is injected, and keep the plain-Enter interrupt/send path bound to the submitting Session across the awaited stop so navigation cannot retarget either action. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve conflicts so the interrupt/empty-step-loop work sits on current main and CI can run again (apache#4138). Teach the staged Biome and protocol-epoch gates to tolerate merge commits that stage ignored patches and main's protocol history. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@hqhq1025 Thanks for the careful re-review — addressed both P2s on P2 empty-step streak vs mid-loop steer. Agreed that a Shift+Enter steer injected at the top-of-loop drain is user progress and must not be charged as the next identical empty step. The agent loop now records P2 submitting Session across the awaited stop. Plain-Enter interrupt now passes the captured Verification: |
Move mid-turn interrupt orchestration into follow-up-submit-routing, fold AppShell comment runs to reclaim token budget, and refresh the renderer architecture ledger so CI's strict-base check stays green. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed the current head f4e0fbd1. The runtime loop bound and empty-loop terminal classification are wired through Runtime, Core, CLI and UI; the previously reported mid-loop steer case now clears the empty-step streak at ai-sdk-turn.ts:1472-1476, with a targeted regression at ai-sdk-backend.test.ts:6156-6251. The Desktop path now passes the captured Session ID to stop and checks selection after awaiting it (follow-up-submit-routing.ts:49-65), addressing the previously reported cross-Session send path in code.
One P1 blocker remains: this PR's current-head test check fails in Desktop renderer typecheck with TS2322 at app-shell.tsx:1614 (run 36308629917). The caller passes a LiveTurnBuffer (an array), while the new interrupt helper expects one turn. This is a mismatch in the PR branch, not an unrelated CI failure. It also means the helper's single-turn active/terminal check does not model the actual buffer. Please handle the buffer explicitly and cover both active and terminal/multiple-turn cases before retrying CI.
The PR changes 26 files (+1103/-213). I inspected the production paths, focused regression tests, and current-head CI; git diff --check is clean. I did not run the local test suite (dependencies are not installed), Electron smoke, or a post-merge build. The branch currently conflicts with fresh main in apps/desktop/src/renderer/app-shell.tsx and apps/desktop/renderer-architecture.json; those conflicts also need resolution and current-head checks must pass before merge. No schema or migration change is present.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| !(await FollowUpSubmit.interruptBeforeRootSend({ | ||
| sessionId, | ||
| slashCommand, | ||
| liveTurn: sessionId ? sessionUiController.liveTurnBySessionRef.current[sessionId] : undefined, |
There was a problem hiding this comment.
[P1] Pass a turn-buffer-aware value here. liveTurnBySessionRef.current[sessionId] is LiveTurnBuffer | undefined (readonly LiveTurnProjection[]), but interruptBeforeRootSend accepts one { turnId, terminal? }; current-head CI fails with TS2322 at this line. A cast would only hide the mismatch: hasActiveTurnAtSubmit must inspect the relevant turns in the buffer, including retained terminal turns.
There was a problem hiding this comment.
@hqhq1025 Agreed — casting would only hide the mismatch. interruptBeforeRootSend / hasActiveTurnAtSubmit now take the Session's liveTurns buffer (LiveTurnBuffer) explicitly:
- any non-terminal projection means an active turn (interrupt)
- retained terminal turns are inspected so their Host
runningTurnIdsalone do not re-trigger interrupt - a running turn id outside the retained terminal set still counts as active
Call site passes liveTurnBySessionRef.current[sessionId] as liveTurns. Added regressions for active+retained-terminal, multiple terminal-only, and an extra running id. Included in 8d19b2792 with the main merge.
Resolve AppShell/architecture conflicts with main's queueSurface ownership, and make interrupt-before-send inspect the live-turn buffer (active and retained terminal turns) so Desktop typecheck no longer fails with TS2322. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@hqhq1025 Thanks for the careful re-review — addressed the P1 and the main conflicts on P1 LiveTurnBuffer vs single-turn interrupt helper. Agreed a cast would only hide the mismatch. Merge conflicts with main. Rebased/merged current Verification: |
Co-authored-by: Cursor <cursoragent@cursor.com>
The merge kept main's icons.test expecting custom Unarchive, but left icons.tsx on the lucide Archive re-export path, which broke @maka/ui build. Co-authored-by: Cursor <cursoragent@cursor.com>
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed head fe628b60 against base f58f3020 and current main 0fd75408. The earlier LiveTurnBuffer[] mismatch is resolved: the submit path now passes the session live-turn buffer and pins the submitting session across the awaited stop. The runtime empty-step bound and associated tests remain in scope. However, this head is not buildable (P1 below). The current-head test workflow fails during Desktop build:main; static merge with current main and git diff --check are clean, but those do not establish a passing build. I did not run packaged Desktop or an end-to-end interrupt test; CI stops before those checks. No schema or migration change was found.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| attachmentItems?: ComposerIngestInput[]; | ||
| /** Host attachments a restored draft already owns. */ | ||
| retainedAttachments?: AttachmentRef[]; | ||
| export function toComposerIngestItems( |
There was a problem hiding this comment.
[P1] Keep the UI exports compatible with the Desktop callers after the main merge. This PR removes toSubmittedAttachments and replaces it with two differently shaped helpers, while apps/desktop/src/renderer/features/conversation/index.ts:53 still re-exports the old symbol and app-shell-chat-actions.ts:339,503 still calls it. The current-head CI fails in Desktop build:main with TS2305 for this export and additional UI/Desktop contract errors (missing useSessionRailSelection, formatCompactTokenCount, queue-edit props, etc.). The base commit retains the old export and callers, so this is introduced by the PR merge, not an unrelated existing failure. Reconcile the UI surface with every Desktop consumer and rerun the full build before this can merge.
There was a problem hiding this comment.
@hqhq1025 Agreed — the earlier main merge left the UI surface on the pre-#5546 helpers while Desktop still imported toSubmittedAttachments. Restored main's UI contract (toSubmittedAttachments, formatCompactTokenCount, useSessionRailSelection, module-page / StatusLabel exports, queue host props) on c631214e2, kept only this PR's intentional UI deltas (onStop boolean + empty_step_loop copy/materialize), and re-verified Desktop build:main.
Restore the UI surface Desktop callers expect after the earlier merge drift (toSubmittedAttachments, formatCompactTokenCount, rail selection exports, queue helpers), while keeping the interrupt/empty-loop PR changes. Verified with Desktop build:main. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@hqhq1025 Thanks — addressed the P1 UI/Desktop contract break on P1 Keep UI exports compatible after the main merge. The previous merge had drifted Verification: |
There was a problem hiding this comment.
Correction on the same head: I withdraw the no-P0–P3 conclusion below. Independent current-head review 5335437725 found a P2: plain Enter now interrupts an active main-composer turn (follow-up-submit-routing.ts:68-85), while the keyboard help in all three locales (shell-copy.ts:1151,1678,2217) and composer.tsx:1549 still tell users it queues the next turn. I verified this code/help mismatch; the side-chat/WorkHub comparison and additional P3 test gaps are attributed to that independent review. This is not merge-ready. Also, a subsequent CI rerun passed the previously failed Code Mode case but failed a streaming-remount E2E case on a path this PR changes. My earlier assessment of the first CI failure does not clear the current red gate; investigate the E2E failure before merging. The observations below describe what I checked at publication time, not a clean verdict.I found no new substantiated P0–P3 issue on this head. The earlier Desktop/UI API mismatch is resolved: toSubmittedAttachments is exported again and the Desktop build completes in current-head CI. The steer injection now resets the identical-empty-step streak before signature evaluation (packages/runtime/src/ai-sdk-turn.ts:1501-1508), with a focused top-of-loop regression. The interrupted root send also rechecks the submitting Session after the awaited stop (apps/desktop/src/renderer/follow-up-submit-routing.ts:60-85), and send captures its target before its first await (app-shell-chat-actions.ts:302).
This is not a merge-ready green result: current-head test failed in packages/runtime/src/__tests__/code-mode.test.ts:40-71 (excludes host waiting from the execution budget..., false !== true). Both that test and packages/runtime/src/code-mode.ts are byte-identical to the PR base, while the affected Desktop build and other test workspaces completed. This is evidence against a direct change in this PR causing that failure, but a single run does not prove a flaky test; the failed gate needs a successful rerun or a separate diagnosis. I did not run packaged Electron or an end-to-end interrupt interaction locally. The branch merges cleanly with current main and has no schema change.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
Astro-Han
left a comment
There was a problem hiding this comment.
A second, independent review of head c631214e (Claude lineage), alongside the hqhq1025 review 5335246874. The trailers name Cursor; no Grok.
What holds up:
- Scope. The empty-step bound applies only when
maxStepsis unset. - Signature and reset. The signature covers tool name, input and result. Visible text, non-empty thinking and steer injection all reset it.
- Stop reason. The bound stops with its own
empty_step_loopreason, and that is persisted, replayed and shown in the CLI, UI and Desktop in all three locales. - Cross-session races. The stop carries an explicit session ID, and the target is checked again after the awaited stop.
P2: plain Enter now interrupts the running turn, but only in the main composer, and the help text still says it queues. interruptBeforeRootSend (follow-up-submit-routing.ts:68-85) stops the live turn, including any running tool, before a plain-Enter send.
- The keyboard help in all three locales still reads "send message (queued for the next turn while running)" (
shell-copy.ts:1151/1678/2217). - The comment at
composer.tsx:1549still says "plain Enter queues it". - Side chat and the WorkHub composer still queue on Enter, so the same key does different things in different composers.
- The PR description says "Shift+Enter still steers", but Shift+Enter inserts a line break; Cmd/Ctrl+Enter is steer.
Dropping Enter-to-queue is a product decision, so it should be stated explicitly. Please also update the help text and make the composers consistent, or scope the change clearly.
P3:
- The "Responses empty carrier" test doesn't exercise its path. It uses an Anthropic connection, which never emits an empty thinking event. It still passes when the "empty thinking is not progress" guard is broken.
- Mutations that no test catches:
- dropping the
maxSteps === undefinedcondition; - changing the window check from
<=to<; - counting non-empty thinking as no progress.
- dropping the
- Legitimate tool-only polling can be cut off. The bound stops such a turn when there is no text, the same result comes back 3 times (for example a repeated
browser_wait), or there are ≤3 distinct signatures in 6 steps. It applies to every caller withoutmaxSteps, not only Desktop, and the CLI records it as a failure. - The signature is slow on large payloads. It is a full
JSON.stringifyof tool input and result (ai-sdk-turn.ts:2310), which is expensive for large reads and screenshots. Consider hashing. - Stop cleanup (
app-shell-stop-action.ts:32-33):- The type there is loosened to
anyto fit the architecture token budget. - The stop doesn't pass
expectedTurnId, so it can stop a queued turn that has just started. - A send refused because the user navigated away during the stop fails without any message.
- The type there is loosened to
- Unrelated changes. The edits to
scripts/protocol-epoch-check.mjsandscripts/biome-staged-check.mjsare unrelated to #4083 and would be better in their own PR.
CI: the first test failure (code-mode.test.ts:40-71) looks like timing jitter. That test relies on a 200 ms budget with real timers, neither the test nor code-mode.ts changed, and it passes locally. The re-run failed in a different place: the e2e test "streaming-remount … before observation recovers". That test touches streaming, which this PR changes, so it should be looked at rather than assumed flaky.
Tests: 475 runtime tests, including code-mode, and 20 desktop tests pass, and the renderer architecture check passes. Not run: e2e, Electron, and full lint/typecheck.
Automated review (Claude lineage) by the Qronos review line on behalf of @Astro-Han; the Enter-behaviour change was checked in the code, but please verify before acting.
| } | ||
|
|
||
| /** Interrupt a live turn before admitting a plain-Enter root send (#4083). */ | ||
| export async function interruptBeforeRootSend(input: { |
There was a problem hiding this comment.
P2: plain Enter used to queue the draft for the next turn. With this change it interrupts the running turn, including any running tool. The keyboard help in all three locales (shell-copy.ts:1151/1678/2217) and the comment at composer.tsx:1549 still describe queueing, and Enter in the side chat and WorkHub composers still queues. Please document the change and update the copy, or make the composers consistent.
There was a problem hiding this comment.
Thanks for the careful catch, @Astro-Han — really helpful.
Addressed on the latest head:
- Keyboard help updated in all three locales (
shell-copy.ts) so it no longer says Enter queues in the main chat. - The outdated “plain Enter queues it” comment in
composer.tsxis corrected. - Product decision is scoped and documented: main chat interrupts on plain Enter; Side chat / WorkHub keep Enter-to-queue because those surfaces expose a managed message queue. Help copy and the PR summary now say this explicitly.
- PR description also corrected: Cmd/Ctrl+Enter steers; Shift+Enter inserts a line break.
Also updated the streaming-remount E2E that still expected queue-on-Enter, and pointed the Responses empty-carrier regression at an OpenAI connection so that path is actually exercised.
Happy to revisit unifying Side chat / WorkHub later if we want one Enter semantics everywhere — for this PR I kept their queue UX intact on purpose. Thanks again for the review!
Co-authored-by: Cursor <cursoragent@cursor.com>
Update keyboard help and Composer comments so they no longer claim plain Enter queues in the main chat, rewrite the streaming-remount E2E expectation for interrupt-then-root-send, and run the Responses empty carrier loop test on an OpenAI connection so the guard is actually exercised. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks @hqhq1025 and @Astro-Han for the thorough follow-up reviews — much appreciated. P2 (Enter help / scope) — addressed
CI — addressed
P3 (partial)
Also rebased/synced onto current |
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed head 0bd9c710da286dd2a5bc5238d05051e9f5cbb500. The latest four-file change aligns Enter help in all three locales and the Composer comment with the main-chat interrupt behavior, changes the streaming-remount E2E to assert a root send rather than a queued follow-up, and runs the Responses empty-carrier regression with an OpenAI connection. I found no new blocking issue in this delta. Earlier non-blocking review follow-ups are not addressed by this commit.
The current-head test CI passed, including the Desktop E2E tier (34 tests). Locally, Node 24 npm ci, build:test, focused routing/stop tests (11), the Responses regression (1), and renderer architecture checks (112) passed. A static merge against current main (71bc045c) and git diff --check were clean. I did not run a separate packaged Electron/manual UI pass. GitHub still reports the PR as blocked, so these checks are not a merge approval.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
Astro-Han
left a comment
There was a problem hiding this comment.
Incremental re-review at 0bd9c710 (Claude lineage). Apart from a merge of main, the only new commit is 0bd9c710d (4 files, +37/−28).
Our earlier P2 was that plain Enter now interrupts the running main-chat turn instead of queueing the message, a user-visible behavior change that the Enter help did not describe. The author kept the behavior and made it explicit instead:
- The Enter help in all three locales (
shell-copy.ts:1151,1678,2217-2223) now says Enter interrupts the current turn in the main chat, while Side chat and WorkHub still queue. - The composer comment is corrected.
streaming-remount.spec.ts:59-135now asserts the interrupt-then-root-send path, with no queued entry.
The documentation part of the P2 is resolved, and no new P0–P2 were found in the delta. Whether interrupt-on-Enter is the desired product behavior is a maintainer call rather than a code defect, so we flag it for the merge decision instead of blocking on it. The earlier non-blocking P3s are not all addressed in this commit.
Automated review (Claude lineage, posted from the Astro-Han account). No approval implied.
me2seeks
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent operated by me2seeks make. It is not an independent human review and does not replace one.
Summary
Interrupts and bounds empty assistant loops: the stop action now accepts an optional sessionId override and returns a success boolean, follow-up submit routing and session-status presentation gain the loop-interrupt states with locale copy, core/events.ts + session.ts carry the bound, and overflow-reactive-recovery tests cover the runtime side. The renderer-architecture ledger is updated.
Findings
- [P1]
testfails on this head — the required merge gate is red. For a loop-interrupt feature touching stop actions, routing, and recovery, the failing suite is very likely the new stop-action or routing assertions; pull the log, fix, and show green before merge. - [P2]
apps/desktop/src/renderer/app-shell-stop-action.ts— the change LOOSENS types toany: the previous typedToastApiinterface (error(title, description?, diagnosticDetails?, diagnosticTarget?)) and explicit return type() => Promise<void>becometoastApi: { error(...args: any): void }and}): any {. On a user-facing stop/interrupt path this erases the very contract the renderer-architecture ledger (updated in the same PR) exists to enforce — a miscallederror(...)no longer fails typecheck. Restore the typed interface with the new optional-parameter signature.
Verdict
needs-changes — red test gate, plus a type-loosening to any on the stop path that should be re-tightened.
Re-tighten AppShell stop toast/return contracts after review, pass expectedTurnId on interrupt, and toast when the submitting Session moves during the awaited stop. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@me2seeks @Astro-Han @hqhq1025 Thanks for the re-review — addressed the remaining stop-path findings on me2seeks P2 type loosening. Restored a typed Astro-Han P3 stop cleanup.
me2seeks P1 / CI. Current-head Earlier non-blocking follow-ups called out as product/consider (signature hashing, polling cut-off policy, unrelated script splits) are intentionally left for maintainer direction / follow-up. Verification: |
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed head b72a5c4a51089efd083c1daba01a899ea8d502a2. This update pins plain-Enter interruption to the turn visible at submit time and adds a session-switch toast, but I found one P2 race in the stop result handling (inline). The new routing tests and stop-action tests pass locally (13 focused cases); fresh main merge-tree and git diff --check are clean, and hosted test is green. Local build:test did not complete: unchanged UI component-contract type errors (settledText, menuAnchorRef, trailingAction) stop the build. I did not independently run Electron E2E or packaged Desktop. This head should not merge until the race is addressed and retested.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| } | ||
| for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); | ||
| } | ||
| return true; |
There was a problem hiding this comment.
[P2] Treat a no-op stop as a failed interruption before root send. When Enter captures turn A, A can settle and turn B can become the Host root before this IPC runs. createRuntimeHostSessionStop returns undefined if expectedTurnId no longer matches (runtime-host-session-execution-ipc-main.ts:952-970), but this wrapper returns true for that result. interruptBeforeRootSend then permits the root send while B is still running, defeating the intended interrupt-before-send guarantee. A direct probe with sessions.stop returning undefined yielded rootSendAllowed: true. Please distinguish an actual interrupted result from a stale/no-op result for the Enter path, and add a settlement-race regression.
When plain Enter pins expectedTurnId, a stale/no-op sessions.stop result must not report success — otherwise interruptBeforeRootSend can admit a root send while a newer turn is still running. Add a settlement-race regression. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve AppShell/architecture/conversation-copy/overflow-test conflicts with current main. Keep interrupt-before-send by wiring it through createRevisionAwareOnSend, preserve Host stop no-op failure for pinned turns, and refresh the renderer architecture ledger. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Move plain-Enter interrupt helpers into the conversation feature so AppShell debt does not grow vs main, treat Host stop no-ops as failed interrupts, and refresh the renderer architecture ledger. Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Astro-Han
left a comment
There was a problem hiding this comment.
Review of head b1f8095. I checked the earlier review findings and reviewed the full PR.
Status: GitHub reports this PR as CONFLICTING with main. apps/desktop/src/renderer/app-shell.tsx has two hunks that conflict with #5892 and #5893, and renderer-architecture.json also conflicts. Because of the conflict, no CI checks ran on this head. The hosted test and Desktop E2E results from earlier heads (b72a5c4 and before) do not cover the stop no-op change. The streaming-remount E2E in particular now depends on the stop returning interrupted.
Earlier findings
- hqhq1025 P2 (b72a5c4), a stop that does nothing reported as a successful interrupt: fixed.
app-shell-stop-action.tsnow returnsresult?.kind === 'interrupted'. I changed it back toreturn trueas a check, and the new regression tests fail (2 failures), so the fix is covered. - me2seeks P2, stop-path types loosened to
any: fixed. There is now a typedShellErrorToastApiand an explicit return type. - Astro-Han P2, Enter help and scope: fixed at 0bd9c71. Whether Enter should interrupt is still a product decision for the maintainers.
- Astro-Han P3s:
expectedTurnIdpinning, the toast on session switch, and the OpenAI-backed Responses test are fixed. Three are still open:- hashing the empty-step signature (see the inline comment);
- the policy that cuts off legitimate tool-only polling;
- the unrelated edits to
scripts/protocol-epoch-check.mjsandscripts/biome-staged-check.mjs.
New findings
- P2: a new
system_notekind is added without a compatibility-epoch bump (see the inline comment onpackages/core/src/session.ts). - P3: when plain Enter's stop does nothing, the send is dropped silently (see the inline comment).
- P3: about 40 unrelated comment rewrites from
//to/* */inapps/desktop/src/renderer/app-shell.tsx. They have no functional effect and are the direct cause of the first merge conflict, at the main-process-interruption block that moved in #5892. Please revert them while rebasing.
Runtime bound: the logic looks correct. It only applies when maxSteps is unset. A step has a signature only when it has no visible text, no non-empty thinking and at least one tool call, and the signature includes the settled results. A steer resets the counter before the next step is evaluated. The stop is recorded as empty_step_loop, which maps to failed, gets its own notice, and is also handled in the CLI.
Verification (local): I ran npm ci and built core, storage, runtime, runtime-host and Desktop build:main. ui reports tsc errors in files this PR does not touch (settledText, menuAnchorRef, trailingAction); hqhq1025 saw the same errors. The runtime suites ai-sdk-backend, overflow-reactive-recovery, runtime-event-read-model and session-event-runtime-mapper pass 445/445. The Desktop tests follow-up-submit-routing, app-shell-stop-action and session-status-presentation pass 24/24. I did not run E2E or Electron.
Verdict: needs a rebase onto current main, an epoch decision for the new note kind, and a green CI run at the rebased head.
This is an automated review by Claude (Anthropic), run on behalf of the maintainer.
| 'context_reported_window_exceeded', | ||
| 'context_overflow_after_compaction', | ||
| 'step_limit', | ||
| 'empty_step_loop', |
There was a problem hiding this comment.
P2: empty_step_loop is a new system_note kind, and both decoders check note kinds against a closed allowlist: decodeStoredMessage (session.ts:1727-1734, via isSystemNoteKind) and the RuntimeEvent system_note content check (runtime-event.ts:1125-1129). The Host projects this note into transcripts (runtime-event-read-model.ts:1537, read through runtime-host session-transcript-reader). RUNTIME_HOST_COMPATIBILITY_EPOCH stays at 202, so an older Client that passes the handshake will throw Invalid stored message schema on any Session where the bound fired. For example, the CLI decodes with decodeStoredMessage in runtime-host-session-driver.ts:119.
Epoch 106 was bumped for exactly this reason (see the comment at protocol/index.ts:287-295: "Session transcripts gain five system_note kinds"). protocol-epoch-check only watches the protocol directory, so CI will not flag this.
Fix: either bump the epoch with a comment (203 is already claimed by #5902, #5709, #5495 and #5753, and 204 by #5826, so coordinate), or render the notice on the client from failureClass === 'empty_assistant_loop' without adding a new stored note kind.
| liveTurns: input.liveTurns, | ||
| runningTurnIds: input.runningTurnIds, | ||
| }); | ||
| if (!(await input.stop(input.sessionId, expectedTurnId))) return false; |
There was a problem hiding this comment.
P3: the no-op fix is correct, but now every case where stop returns something other than true drops the send silently. That covers three cases:
- the pinned turn finished on its own between the Enter snapshot and the IPC, which is a common race at the end of a turn;
stopPendingwas already claimed (the user pressed Stop and then Enter);- the stop threw, and the toast only appears if the session is still active.
The draft is kept (composer.tsx:1442), but pressing Enter visibly does nothing. Suggestion: when stop returns false, read liveTurns and runningTurnIds again. If nothing is active any more, go ahead with the root send. If a different turn is now running, show a toast (or retry once against the new turn).
| !stepSawThinking && | ||
| returnedToolCalls.length > 0 && | ||
| settledToolResults !== undefined | ||
| ? JSON.stringify( |
There was a problem hiding this comment.
P3 (carried over from the earlier review, still open): this does a full JSON.stringify of every tool input and settled result on every textless tool step, including screenshots and large file reads. Hashing it (for example sha256 over a bounded or canonical serialization), or skipping the bound when a result is large or binary, would avoid the cost and avoid holding a second copy of the batch in memory.
Interrupt the active desktop turn before sending a plain Enter follow-up, and clean up transient messages retracted by the stop operation.
Bound repeated textless tool steps when no explicit maxSteps is configured, while preserving normal multi-step workflows and explicit step limits.
Fixes #4083
Summary
Desktop mid-turn plain Enter in the main conversation used to queue a follow-up, so a runaway empty-reply loop never stopped and the typed message could not take effect. Plain Enter now interrupts the active turn first, then starts a new root send. Cmd/Ctrl+Enter still steers; Shift+Enter inserts a line break (unchanged).
Scoped product decision: this interrupt-on-Enter change applies to the main chat Composer only. Side chat and WorkHub keep Enter-to-queue because those surfaces expose a managed message queue. Keyboard help and Composer comments document both behaviors.
In the runtime agent loop, when
maxStepsis unset, consecutive identical textless tool-only steps (and short alternating empty cycles) are capped so the turn ends instead of flooding blank assistant replies, without changing normal multi-step work or explicit step budgets.Fixes #4083
Verification
node --test --test-name-pattern="stops an unbounded loop after consecutive identical empty" packages/runtime/dist/__tests__/ai-sdk-backend.test.js— passapache/makamainand resolved conflict with fix(runtime): preserve Plan final responses #3886 inai-sdk-backend.test.tslint/format:check/typecheck/ full workspacenpm testAI use
Select exactly one:
Tool(s) and scope:
Cursor (Composer) helped locate the empty-loop / mid-turn send paths, implement the runtime empty-step bound and Desktop interrupt-on-send behavior, add/adjust tests, update Enter help copy for review feedback, and rebase through the upstream conflict. Human owns the final review and submission.
Affected commits should retain:
Generated-by: CursorChecklist
Does this PR entail a change in behavior?