Skip to content

feat(chat): make Steer messages IM-style - #2070

Merged
zerob13 merged 6 commits into
devfrom
codex/steer-message-lifecycle
Jul 31, 2026
Merged

feat(chat): make Steer messages IM-style#2070
zerob13 merged 6 commits into
devfrom
codex/steer-message-lifecycle

Conversation

@zerob13

@zerob13 zerob13 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • persist every accepted Steer as a normal user message immediately
  • keep the receipt lifecycle to two named states: Unread until runtime claim, then Read, then no receipt
  • allow Steer while the active turn is still preparing and no assistant stream message exists yet
  • end the old operation at the backend-safe pending_input boundary and start the response in a new assistant row
  • keep Queue as the editable/reorderable draft lane; Steer remains an immutable sent conversation fact

Why

Steer previously behaved like a bottom command: before the first assistant stream update it was disabled, and after acceptance it used loading/pending UI instead of appearing in the conversation. That made admission timing and response ownership unclear.

The interaction now follows an IM model. A Steer appears in the message list while the other side is preparing or typing, becomes Read when the loop claims it, and receives a distinct assistant response below it.

UI: before / after

Before: pre-stream Steer was blocked

+----------------------------------------------------------+
|                                      You                 |
|                                  +---------------------+ |
|                                  | Start the analysis. | |
|                                  +---------------------+ |
|                                                          |
| Assistant is preparing...                                |
| [Change the approach...                           ]      |
| [Steer disabled] [Queue]                                 |
+----------------------------------------------------------+

After: Steer is sent immediately

+----------------------------------------------------------+
|                                      You                 |
|                                  +---------------------+ |
|                                  | Start the analysis. | |
|                                  +---------------------+ |
|                                      You · Unread        |
|                                  +---------------------+ |
|                                  | Change the approach | |
|                                  +---------------------+ |
|                                                          |
| Assistant is preparing...                                |
+----------------------------------------------------------+

After the safe read boundary

+----------------------------------------------------------+
|                                      You · Read          |
|                                  +---------------------+ |
|                                  | Change the approach | |
|                                  +---------------------+ |
|                                                          |
| Assistant B                                              |
| New response starts in a new message...                  |
+----------------------------------------------------------+

Read stays visible until readAt + 1.5s, fades for 150 ms, and disappears. Reduced-motion mode skips the fade. The receipt stays in the existing fixed-height message-info line, so row height and virtual-scroll geometry do not change.

Behavior and architecture

  • Pre-stream acceptance atomically materializes or reuses the active source user fact before assigning the Steer's orderSeq.
  • The persisted source record replaces the renderer's optimistic source bubble in the same update, avoiding a duplicate message.
  • DeepChat aborts pre-stream preparation with cause pending_input, removes an empty assistant reservation if one exists, settles the source claim, and lets the pending-input pump claim Steer.
  • ACP applies the same source-before-Steer ordering before cancelling its pre-projection operation with pending_input.
  • Rapid Steers remain separate user bubbles while sharing the existing merged runtime payload until claim.
  • Claim atomically stamps linked Steers as read and reserves the next assistant message ID.
  • Active-stream Steer still waits for the existing safe loop boundary; new output never appends to the old assistant row.
  • Restart recovery preserves the materialized source fact and resumes the pending Steer without replaying the source input.
  • Renderer enablement now follows active-turn state and real blockers, not currentStreamMessageId availability.
  • The SDD in docs/features/im-style-steer-messages/ contains the complete UI states, ordering invariants, failure behavior, implementation map, and release QA.

Test scope

The regression suite stays lean: existing cases were repurposed for the new contract, and the obsolete disabled-tooltip case was removed rather than adding a parallel test matrix.

Validation

  • pnpm run format
  • pnpm run i18n — 20 locales, no missing or invalid keys
  • pnpm run lint
  • pnpm run typecheck — main and renderer
  • affected DeepChat, ACP, session-data, composer, toolbar, ChatPage, and message-store suites — 469 passed
  • 9 native-SQLite cases skipped because the Node test process does not use the Electron ABI

Manual release QA

  • real-provider DeepChat and ACP pre-stream handoff
  • light/dark themes and narrow/resized windows
  • keyboard, screen-reader, and reduced-motion behavior
  • text, file/image, mention, and active-Skill Steers
  • long-history virtualized scroll-follow behavior

Summary by CodeRabbit

  • New Features

    • Steer messages are now durably saved in chat history and restored after session recovery.
    • Unread/read receipt indicators now include accessible labels and localized translations.
    • Accepted steer messages appear directly in the conversation, while queued inputs remain separate.
    • Message updates sync across active session views automatically.
    • Steering status and pending-input cancellation behavior are clearer and more reliable.
  • Documentation

    • Added specifications, implementation plans, architecture guidance, and delivery records for steer-message behavior.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change implements durable IM-style Steer messages. Steers persist as transcript messages, support batching and recovery, reuse ACP projections, publish renderer updates, expose read receipts, and keep pending-input UI focused on Queue items.

Changes

Durable Steer message lifecycle

Layer / File(s) Summary
Contracts and transcript persistence
docs/architecture/session-management.md, docs/features/im-style-steer-messages/*, src/shared/contracts/*, src/shared/types/*, src/main/session/data/*, src/main/data/schemaCatalog.ts
Defines Queue and Steer lifecycle contracts. Persists linked message and assistant identifiers. Adds migration 46, receipt transitions, transactional claiming, settlement, recovery, and message-change events.
Runtime and ACP handoff
src/main/agent/acp/*, src/main/agent/deepchat/*, src/main/agent/manager/*, src/main/session/chatService.ts, src/main/session/turn.ts
Adds durable Steer admission, pending-input resumption, reserved transcript startup, pending_input cancellation, ACP projection reuse, and removal of direct conversion APIs.
Renderer synchronization and receipt UI
src/renderer/api/*, src/renderer/src/stores/ui/*, src/renderer/src/features/chat-page/*, src/renderer/src/components/*, src/renderer/src/i18n/*
Applies persisted message events, renders unread/read receipts, restricts pending-message actions, and keeps the pending lane Queue-only.
Regression coverage
test/main/*, test/renderer/*
Covers persistence, recovery, batching, cancellation, stream handoff, ACP resumption, event ordering, receipt timing, Queue filtering, and steering races.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: yyhhyyyyyy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the PR's primary change: making Steer messages behave like IM-style sent conversation messages.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/steer-message-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zerob13
zerob13 marked this pull request as ready for review July 31, 2026 02:05
@dosubot

dosubot Bot commented Jul 31, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-08-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about deepchat Add Dosu to your team

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/session/chatService.ts (1)

185-191: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Replace the non-null assertion on result.userMessage with an explicit check.

result.userMessage is optional on the turn result. The assertion at line 187 silences that, so message can be undefined at runtime. The route contract in src/shared/contracts/routes/chat.routes.ts requires message: ChatMessageRecordSchema for accepted: true. An absent userMessage therefore fails Zod validation at the IPC boundary and reports a schema error instead of the real cause.

The current reject branch only covers attachmentPreparation?.status === 'needs_user_action'. Any other outcome that produces no persisted user message reaches this path.

Fail fast with a clear error, or map the missing message to the rejected variant.

🐛 Proposed fix
       if (result.attachmentPreparation?.status === 'needs_user_action') {
         return {
           accepted: false,
           message: null,
           attachmentPreparation: result.attachmentPreparation
         }
       }
 
+      if (!result.userMessage) {
+        throw new Error(`Steer accepted without a persisted user message: ${sessionId}`)
+      }
+
       return {
         accepted: true,
-        message: result.userMessage!,
+        message: result.userMessage,
         ...(result.attachmentPreparation
           ? { attachmentPreparation: result.attachmentPreparation }
           : {})
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/session/chatService.ts` around lines 185 - 191, Replace the non-null
assertion in the accepted result branch of chatService with an explicit
result.userMessage check. When the message is absent, fail clearly or return the
rejected variant so accepted responses always contain a valid ChatMessageRecord;
preserve the existing attachmentPreparation handling for valid messages.
test/main/session/session.integration.test.ts (1)

2388-2405: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Confirm the mocked steerPendingInput responses match the production flow.

convertPendingInputToSteer delegates directly to steerPendingInput, and both routes call steerPendingInput once. If the real route path does not invoke this twice for one user action, this test should use the same resolved value for both calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/main/session/session.integration.test.ts` around lines 2388 - 2405, The
test’s mocked steerPendingInput responses must reflect the production call flow.
Review convertPendingInputToSteer and steerPendingInput, then update the mock
setup and assertions so each user action expects the actual number of calls and
uses the same resolved value for both calls when production invokes them
equivalently.
🧹 Nitpick comments (3)
src/main/session/data/transcript.ts (1)

285-325: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Reuse the record you already read instead of a second read pass.

Both methods read each message twice. The loop already produces updated for every id, then line 311 and line 324 read every message again through requireMessage. Collect the records in the loop and return them.

♻️ Proposed change
   markSteerMessagesRead(messageIds: string[], readAt: number): ChatMessageRecord[] {
+    const updatedRecords: ChatMessageRecord[] = []
     for (const messageId of messageIds) {
@@
       const updated = this.getMessage(messageId)
       if (!updated) {
         throw new Error(`Failed to mark steer message read: ${messageId}`)
       }
       this.tapeFacts.appendMessageReplacement(updated, 'steer_message_read')
+      updatedRecords.push(updated)
     }
-    return messageIds.map((messageId) => this.requireMessage(messageId))
+    return updatedRecords
   }
 
   settleSteerMessages(messageIds: string[]): ChatMessageRecord[] {
+    const updatedRecords: ChatMessageRecord[] = []
     for (const messageId of messageIds) {
@@
       const updated = this.requireMessage(messageId)
       this.tapeFacts.appendMessageReplacement(updated, 'steer_message_settled')
+      updatedRecords.push(updated)
     }
-    return messageIds.map((messageId) => this.requireMessage(messageId))
+    return updatedRecords
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/session/data/transcript.ts` around lines 285 - 325, Update
markSteerMessagesRead and settleSteerMessages to collect each successfully
retrieved updated record in an array during the existing loop, then return that
array directly instead of mapping messageIds through requireMessage for a second
read.
src/main/session/data/pendingInputStore.ts (1)

536-545: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Log invalid message_ids_json before falling back.

decodePayload and decodeBlocking log corrupt JSON. decodeMessageIds returns [] silently. A silent fallback drops the transcript link. The steer item then fails the claim check with "has no linked messages", and restart recovery creates a second user message for the same input. A log line makes that state diagnosable.

♻️ Proposed logging
   private decodeMessageIds(row: DeepChatPendingInputRow): string[] {
     try {
       const parsed = JSON.parse(row.message_ids_json) as unknown
       return Array.isArray(parsed)
         ? parsed.filter((value): value is string => typeof value === 'string' && value.length > 0)
         : []
-    } catch {
+    } catch (error) {
+      console.error(
+        `[DeepChatPendingInputStore] Invalid pending input message IDs JSON: ${row.id}`,
+        error
+      )
       return []
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/session/data/pendingInputStore.ts` around lines 536 - 545, Update
decodeMessageIds in the JSON.parse error path to log that row.message_ids_json
is invalid before returning the existing empty array fallback, matching the
diagnostics used by decodePayload and decodeBlocking. Keep valid-array filtering
and non-array behavior unchanged.
src/main/session/data/pendingInputs.ts (1)

91-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the steer user-message creation into one helper.

The same createUserMessage call with status: 'pending' and the inputReceipt metadata appears three times in this file: lines 91-104, 134-147, and 342-355. One helper keeps the receipt contract in a single place, so a future change to the receipt shape cannot diverge between accept, promote, and recovery.

♻️ Proposed helper
+  private createPendingSteerMessage(sessionId: string, input: SendMessageInput): string {
+    return this.transcript.createUserMessage(
+      sessionId,
+      this.transcript.getNextOrderSeq(sessionId),
+      toUserMessageContent(input),
+      {
+        status: 'pending',
+        metadata: {
+          inputReceipt: {
+            mode: 'steer',
+            readAt: null
+          }
+        }
+      }
+    )
+  }

Then call this.createPendingSteerMessage(sessionId, input) in acceptSteerMessage, promoteQueuedInputToSteerMessage, and recoverClaimedInputsAfterRestart.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/session/data/pendingInputs.ts` around lines 91 - 104, Extract the
duplicated pending steer createUserMessage logic into a
createPendingSteerMessage helper on the containing class, preserving the
existing sessionId, input conversion, pending status, and steer inputReceipt
metadata. Replace the inline implementations in acceptSteerMessage,
promoteQueuedInputToSteerMessage, and recoverClaimedInputsAfterRestart with
calls to this helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/agent/acp/instance/acpAgentInstance.ts`:
- Around line 347-350: The cancel method currently overwrites an existing
cancellation cause during repeated cancellation. Update AcpAgentInstance.cancel
so active.cancelCause is assigned only when no cause has been recorded,
preserving the initial pending_input cause; add a regression test that calls
cancel('pending_input') followed by cancel() before the aborted prompt settles
and verifies the original cause is retained.

In `@src/main/agent/deepchat/runtime/pendingInputAdmissionCoordinator.ts`:
- Around line 333-337: Update the active-generation branch in the pending input
admission coordinator to retain the promoted item’s Steer batch: merge it into
an existing pending Steer batch when available, otherwise call
setActiveSteerPendingInputId() with the promoted record. Ensure
acceptVisibleSteerInput() can use the resulting batch as mergeItemId for the
next direct Steer, and add a regression test covering Queue promotion followed
by direct Steer.

In `@src/main/agent/manager/directAcpAgentBackend.ts`:
- Around line 138-143: Update the async list() method to re-read pending inputs
from runtime.listPendingInputs(sessionId) after resumePendingInputs completes,
returning that post-resume snapshot instead of the initial inputs. Add a
recovery regression test covering pending.list() after a Steer is claimed,
verifying it returns the updated receipt state.

In `@src/main/session/data/pendingInputs.ts`:
- Around line 323-327: Update the blocked-input recovery branch in the
pending-inputs flow to settle or retract all messages linked to the blocked
steer, including its reserved assistant message, before calling
convertSteerInputToQueue. Reuse the existing message-settlement/retraction and
assistant-reservation cleanup mechanisms, then requeue the payload while
preserving sessionIds tracking and loop control.

In `@src/renderer/src/components/message/MessageItemUser.vue`:
- Around line 183-189: Update the effectiveReadOnly computed property in
MessageItemUser so accepted Steer messages with inputReceipt metadata remain
read-only, in addition to the existing isReadOnly and pending-status conditions.
Preserve edit, retry, and delete availability for other messages.

In `@test/renderer/features/chat-page/composables/useComposerSubmit.test.ts`:
- Line 740: Remove the duplicate const steering declarations in each affected
test scope, retaining exactly one createDeferred declaration at
test/renderer/features/chat-page/composables/useComposerSubmit.test.ts lines
740-740 and 789-789.

---

Outside diff comments:
In `@src/main/session/chatService.ts`:
- Around line 185-191: Replace the non-null assertion in the accepted result
branch of chatService with an explicit result.userMessage check. When the
message is absent, fail clearly or return the rejected variant so accepted
responses always contain a valid ChatMessageRecord; preserve the existing
attachmentPreparation handling for valid messages.

In `@test/main/session/session.integration.test.ts`:
- Around line 2388-2405: The test’s mocked steerPendingInput responses must
reflect the production call flow. Review convertPendingInputToSteer and
steerPendingInput, then update the mock setup and assertions so each user action
expects the actual number of calls and uses the same resolved value for both
calls when production invokes them equivalently.

---

Nitpick comments:
In `@src/main/session/data/pendingInputs.ts`:
- Around line 91-104: Extract the duplicated pending steer createUserMessage
logic into a createPendingSteerMessage helper on the containing class,
preserving the existing sessionId, input conversion, pending status, and steer
inputReceipt metadata. Replace the inline implementations in acceptSteerMessage,
promoteQueuedInputToSteerMessage, and recoverClaimedInputsAfterRestart with
calls to this helper.

In `@src/main/session/data/pendingInputStore.ts`:
- Around line 536-545: Update decodeMessageIds in the JSON.parse error path to
log that row.message_ids_json is invalid before returning the existing empty
array fallback, matching the diagnostics used by decodePayload and
decodeBlocking. Keep valid-array filtering and non-array behavior unchanged.

In `@src/main/session/data/transcript.ts`:
- Around line 285-325: Update markSteerMessagesRead and settleSteerMessages to
collect each successfully retrieved updated record in an array during the
existing loop, then return that array directly instead of mapping messageIds
through requireMessage for a second read.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86ea8d60-ae26-43c5-8dd7-1909ff771c4f

📥 Commits

Reviewing files that changed from the base of the PR and between b32b4d4 and 1f55a3e.

📒 Files selected for processing (88)
  • docs/architecture/session-management.md
  • docs/features/im-style-steer-messages/plan.md
  • docs/features/im-style-steer-messages/spec.md
  • docs/features/im-style-steer-messages/tasks.md
  • src/main/agent/acp/compatibility/adapters.ts
  • src/main/agent/acp/instance/acpAgentInstance.ts
  • src/main/agent/acp/instance/acpAgentRuntime.ts
  • src/main/agent/acp/instance/ports.ts
  • src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts
  • src/main/agent/deepchat/harness/deepChatAgentHarness.ts
  • src/main/agent/deepchat/runtime/pendingInputAdmissionCoordinator.ts
  • src/main/agent/deepchat/runtime/pendingInputContracts.ts
  • src/main/agent/deepchat/runtime/pendingInputPump.ts
  • src/main/agent/deepchat/runtime/turnCoordinator.ts
  • src/main/agent/manager/deepChatAgentBackend.ts
  • src/main/agent/manager/directAcpAgentBackend.ts
  • src/main/agent/manager/sessionHandles.ts
  • src/main/app/composition.ts
  • src/main/data/schemaCatalog.ts
  • src/main/session/chatService.ts
  • src/main/session/data/contracts.ts
  • src/main/session/data/index.ts
  • src/main/session/data/pendingInputStore.ts
  • src/main/session/data/pendingInputs.ts
  • src/main/session/data/tables/deepchatPendingInputs.ts
  • src/main/session/data/transcript.ts
  • src/main/session/turn.ts
  • src/renderer/api/SessionClient.ts
  • src/renderer/src/components/chat/ChatInputToolbar.vue
  • src/renderer/src/components/chat/PendingInputLane.vue
  • src/renderer/src/components/message/MessageInfo.vue
  • src/renderer/src/components/message/MessageItemUser.vue
  • src/renderer/src/features/chat-page/ChatPage.vue
  • src/renderer/src/features/chat-page/composables/useComposerSubmit.ts
  • src/renderer/src/features/chat-page/composables/useDisplayMessages.ts
  • src/renderer/src/features/chat-page/model/displayMessage.ts
  • src/renderer/src/i18n/da-DK/chat.json
  • src/renderer/src/i18n/de-DE/chat.json
  • src/renderer/src/i18n/en-US/chat.json
  • src/renderer/src/i18n/es-ES/chat.json
  • src/renderer/src/i18n/fa-IR/chat.json
  • src/renderer/src/i18n/fr-FR/chat.json
  • src/renderer/src/i18n/he-IL/chat.json
  • src/renderer/src/i18n/id-ID/chat.json
  • src/renderer/src/i18n/it-IT/chat.json
  • src/renderer/src/i18n/ja-JP/chat.json
  • src/renderer/src/i18n/ko-KR/chat.json
  • src/renderer/src/i18n/ms-MY/chat.json
  • src/renderer/src/i18n/pl-PL/chat.json
  • src/renderer/src/i18n/pt-BR/chat.json
  • src/renderer/src/i18n/ru-RU/chat.json
  • src/renderer/src/i18n/tr-TR/chat.json
  • src/renderer/src/i18n/vi-VN/chat.json
  • src/renderer/src/i18n/zh-CN/chat.json
  • src/renderer/src/i18n/zh-HK/chat.json
  • src/renderer/src/i18n/zh-TW/chat.json
  • src/renderer/src/stores/ui/message.ts
  • src/renderer/src/stores/ui/messageIpc.ts
  • src/renderer/src/stores/ui/pendingInput.ts
  • src/shared/contracts/events.ts
  • src/shared/contracts/events/sessions.events.ts
  • src/shared/contracts/routes/chat.routes.ts
  • src/shared/contracts/routes/sessions.routes.ts
  • src/shared/types/agent-interface.d.ts
  • test/main/agent/acp/instance/acpAgentRuntime.test.ts
  • test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts
  • test/main/agent/deepchat/runtime/pendingInputAdmissionCoordinator.test.ts
  • test/main/agent/deepchat/runtime/pendingInputPump.test.ts
  • test/main/agent/manager/agentManager.test.ts
  • test/main/agent/manager/deepChatAgentBackend.test.ts
  • test/main/agent/manager/directAcpAgentBackend.test.ts
  • test/main/routes/dispatcher.test.ts
  • test/main/session/assignment.test.ts
  • test/main/session/chatService.test.ts
  • test/main/session/data/pendingInputStore.test.ts
  • test/main/session/data/pendingInputs.test.ts
  • test/main/session/data/tables/deepchatPendingInputsTable.test.ts
  • test/main/session/runtimeIntegration.test.ts
  • test/main/session/session.integration.test.ts
  • test/main/session/turn.test.ts
  • test/renderer/components/ChatInputToolbar.test.ts
  • test/renderer/components/ChatPage.test.ts
  • test/renderer/components/PendingInputLane.test.ts
  • test/renderer/components/message/MessageItemUser.test.ts
  • test/renderer/features/chat-page/composables/useComposerSubmit.test.ts
  • test/renderer/stores/messageStore.reactivity.test.ts
  • test/renderer/stores/messageStore.test.ts
  • test/renderer/stores/pendingInputStore.test.ts
💤 Files with no reviewable changes (7)
  • src/main/agent/manager/sessionHandles.ts
  • src/renderer/src/stores/ui/pendingInput.ts
  • test/main/agent/manager/agentManager.test.ts
  • src/main/agent/manager/deepChatAgentBackend.ts
  • src/main/agent/deepchat/harness/deepChatAgentHarness.ts
  • test/main/session/assignment.test.ts
  • test/main/agent/manager/deepChatAgentBackend.test.ts

Comment thread src/main/agent/acp/instance/acpAgentInstance.ts Outdated
Comment thread src/main/agent/deepchat/runtime/pendingInputAdmissionCoordinator.ts
Comment thread src/main/agent/manager/directAcpAgentBackend.ts
Comment on lines +323 to +327
if (input.state === 'blocked') {
this.store.convertSteerInputToQueue(input.id)
sessionIds.add(input.sessionId)
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Blocked steer recovery orphans the linked transcript messages.

A steer input can only reach state blocked through blockClaimedInput, which requires state claimed. claimSteerInput (line 217) rejects a claim when messageIds is empty, and it also reserves an assistant message. So every blocked steer row carries at least one linked user message and an assistantMessageId.

Recovery converts that row back to a queue draft. The linked user messages stay pending with inputReceipt.readAt set, and SessionTranscript.shouldKeepPending keeps them pending forever. The reserved assistant message also stays pending. The user then sees a permanently unsettled steer message in the transcript plus the same text again as an editable Queue draft.

Settle or retract the linked messages before you requeue the payload, and clear the assistant reservation.

🐛 Proposed direction
         if (input.state === 'blocked') {
+          if (input.messageIds.length > 0 || input.assistantMessageId) {
+            // Settle the sent conversation facts, then requeue only the editable draft.
+            this.transcript.settleSteerMessages(input.messageIds)
+          }
           this.store.convertSteerInputToQueue(input.id)
           sessionIds.add(input.sessionId)
           continue
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/session/data/pendingInputs.ts` around lines 323 - 327, Update the
blocked-input recovery branch in the pending-inputs flow to settle or retract
all messages linked to the blocked steer, including its reserved assistant
message, before calling convertSteerInputToQueue. Reuse the existing
message-settlement/retraction and assistant-reservation cleanup mechanisms, then
requeue the payload while preserving sessionIds tracking and loop control.

Comment on lines +183 to +189
const receipt = ref<'unread' | 'read' | null>(null)
let receiptTimer: ReturnType<typeof setTimeout> | null = null

const effectiveReadOnly = computed(() => props.isReadOnly || props.message.status === 'pending')
const receiptLabel = computed(() =>
receipt.value ? t(`chat.messageReceipt.${receipt.value}`) : undefined
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep accepted Steer messages immutable.

Line 186 only marks pending messages as read-only. An accepted Steer is a persisted sent user message with inputReceipt, so edit, retry, and delete become available after acceptance. Include Steer receipt metadata in effectiveReadOnly.

Proposed fix
-const effectiveReadOnly = computed(() => props.isReadOnly || props.message.status === 'pending')
+const effectiveReadOnly = computed(
+  () =>
+    props.isReadOnly ||
+    props.message.status === 'pending' ||
+    props.message.inputReceipt?.mode === 'steer'
+)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const receipt = ref<'unread' | 'read' | null>(null)
let receiptTimer: ReturnType<typeof setTimeout> | null = null
const effectiveReadOnly = computed(() => props.isReadOnly || props.message.status === 'pending')
const receiptLabel = computed(() =>
receipt.value ? t(`chat.messageReceipt.${receipt.value}`) : undefined
)
const receipt = ref<'unread' | 'read' | null>(null)
let receiptTimer: ReturnType<typeof setTimeout> | null = null
const effectiveReadOnly = computed(
() =>
props.isReadOnly ||
props.message.status === 'pending' ||
props.message.inputReceipt?.mode === 'steer'
)
const receiptLabel = computed(() =>
receipt.value ? t(`chat.messageReceipt.${receipt.value}`) : undefined
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/src/components/message/MessageItemUser.vue` around lines 183 -
189, Update the effectiveReadOnly computed property in MessageItemUser so
accepted Steer messages with inputReceipt metadata remain read-only, in addition
to the existing isReadOnly and pending-status conditions. Preserve edit, retry,
and delete availability for other messages.


it('blocks duplicates and clears the draft only after acceptance', async () => {
const steering = createDeferred<{ accepted: boolean }>()
const steering = createDeferred<{ accepted: true; message: ChatMessageRecord }>()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicate steering declarations.

Each test scope declares const steering twice. TypeScript rejects duplicate block-scoped declarations, so this test file does not compile.

  • test/renderer/features/chat-page/composables/useComposerSubmit.test.ts#L740-L740: Keep one createDeferred declaration.
  • test/renderer/features/chat-page/composables/useComposerSubmit.test.ts#L789-L789: Keep one createDeferred declaration.
📍 Affects 1 file
  • test/renderer/features/chat-page/composables/useComposerSubmit.test.ts#L740-L740 (this comment)
  • test/renderer/features/chat-page/composables/useComposerSubmit.test.ts#L789-L789
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/renderer/features/chat-page/composables/useComposerSubmit.test.ts` at
line 740, Remove the duplicate const steering declarations in each affected test
scope, retaining exactly one createDeferred declaration at
test/renderer/features/chat-page/composables/useComposerSubmit.test.ts lines
740-740 and 789-789.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/main/agent/acp/instance/acpAgentInstance.test.ts`:
- Around line 313-326: Update the test “keeps first-turn readiness and the first
cancellation cause stable” to assert connection.cancel was called once after the
cancellation operations complete, while retaining the earlier connection.prompt
assertion for readiness and the existing cancelCauses expectation.

In `@test/main/agent/manager/directAcpAgentBackend.test.ts`:
- Around line 341-348: Strengthen the test “exposes direct transfer, pending,
subagent, generation, and close facets” by recording lifecycle events in the
resume, runtime-close, and cleanup-session mocks. After awaiting handle.close(),
assert that resume completion precedes both runtime.close and cleanupSession,
while preserving the existing pending-input assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf16993-4bec-4c81-b1e5-b6d6cbfcd465

📥 Commits

Reviewing files that changed from the base of the PR and between 5185a76 and 0dd8307.

📒 Files selected for processing (5)
  • src/main/agent/acp/instance/acpAgentInstance.ts
  • src/main/agent/manager/directAcpAgentBackend.ts
  • src/main/session/chatService.ts
  • test/main/agent/acp/instance/acpAgentInstance.test.ts
  • test/main/agent/manager/directAcpAgentBackend.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/session/chatService.ts
  • src/main/agent/manager/directAcpAgentBackend.ts
  • src/main/agent/acp/instance/acpAgentInstance.ts

Comment on lines +313 to +326
it('keeps first-turn readiness and the first cancellation cause stable', async () => {
const harness = createHarness({ promptNeverSettles: true })
const sending = harness.instance.send('hello')

await expect(harness.instance.waitForFirstTurnReady({ timeoutMs: 100 })).resolves.toBe(true)
expect(harness.connection.prompt).toHaveBeenCalledTimes(1)

await harness.instance.cancel()
const pendingInputCancellation = harness.instance.cancel('pending_input')
const repeatedCancellation = harness.instance.cancel()
await Promise.all([pendingInputCancellation, repeatedCancellation])
await sending

expect(harness.connection.prompt).toHaveBeenCalledTimes(1)
expect(harness.cancelCauses).toEqual(['pending_input'])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the cancellation operation.

Line 325 checks connection.prompt, which only proves that the prompt started. It does not prove that cancellation happened once. Assert connection.cancel instead. Keep the earlier prompt assertion for readiness.

Suggested assertion
-    expect(harness.connection.prompt).toHaveBeenCalledTimes(1)
+    expect(harness.connection.cancel).toHaveBeenCalledTimes(1)

As per coding guidelines: Add the smallest regression test for user-visible behavior or a documented contract.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('keeps first-turn readiness and the first cancellation cause stable', async () => {
const harness = createHarness({ promptNeverSettles: true })
const sending = harness.instance.send('hello')
await expect(harness.instance.waitForFirstTurnReady({ timeoutMs: 100 })).resolves.toBe(true)
expect(harness.connection.prompt).toHaveBeenCalledTimes(1)
await harness.instance.cancel()
const pendingInputCancellation = harness.instance.cancel('pending_input')
const repeatedCancellation = harness.instance.cancel()
await Promise.all([pendingInputCancellation, repeatedCancellation])
await sending
expect(harness.connection.prompt).toHaveBeenCalledTimes(1)
expect(harness.cancelCauses).toEqual(['pending_input'])
it('keeps first-turn readiness and the first cancellation cause stable', async () => {
const harness = createHarness({ promptNeverSettles: true })
const sending = harness.instance.send('hello')
await expect(harness.instance.waitForFirstTurnReady({ timeoutMs: 100 })).resolves.toBe(true)
expect(harness.connection.prompt).toHaveBeenCalledTimes(1)
const pendingInputCancellation = harness.instance.cancel('pending_input')
const repeatedCancellation = harness.instance.cancel()
await Promise.all([pendingInputCancellation, repeatedCancellation])
await sending
expect(harness.connection.cancel).toHaveBeenCalledTimes(1)
expect(harness.cancelCauses).toEqual(['pending_input'])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/main/agent/acp/instance/acpAgentInstance.test.ts` around lines 313 -
326, Update the test “keeps first-turn readiness and the first cancellation
cause stable” to assert connection.cancel was called once after the cancellation
operations complete, while retaining the earlier connection.prompt assertion for
readiness and the existing cancelCauses expectation.

Source: Coding guidelines

Comment on lines +341 to +348
it('exposes direct transfer, pending, subagent, generation, and close facets', async () => {
const harness = createHarness()
const handle = harness.backend.open(sessionId, descriptor)
const pendingBeforeResume = { id: 'steer', mode: 'steer', state: 'pending' }
const pendingAfterResume = { ...pendingBeforeResume, state: 'claimed' }
harness.runtime.listPendingInputs
.mockReturnValueOnce([pendingBeforeResume])
.mockReturnValueOnce([pendingAfterResume])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Verify resume completion before cleanup.

The assertion at Line 379 proves only that resumePendingInputs was called. The preconfigured results at Lines 344-348 and the assertion at Line 362 do not prove that resume completed before handle.close() finished or before runtime.close and cleanupSession ran.

Record lifecycle events in the mocks. Assert that resume completion occurs before cleanup. The runtime method hydrates the session and drains pending inputs, so cleanup-first ordering can lose recovery work.

As per coding guidelines: Add the smallest regression test for user-visible behavior or a documented contract.

Also applies to: 362-362, 379-381

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/main/agent/manager/directAcpAgentBackend.test.ts` around lines 341 -
348, Strengthen the test “exposes direct transfer, pending, subagent,
generation, and close facets” by recording lifecycle events in the resume,
runtime-close, and cleanup-session mocks. After awaiting handle.close(), assert
that resume completion precedes both runtime.close and cleanupSession, while
preserving the existing pending-input assertions.

Source: Coding guidelines

@zerob13
zerob13 merged commit 2669269 into dev Jul 31, 2026
12 checks passed
@zhangmo8
zhangmo8 deleted the codex/steer-message-lifecycle branch July 31, 2026 10:22
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