Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1944,6 +1944,9 @@ const ConversationTabView = memo(function ConversationTabView({
connectionId: conn.connectionId,
connStatus,
enabled: feedbackEnabled,
// Notes the transcript adopted as mid-turn user turns show as messages,
// not as strips above the composer.
steeredMessageIds: conn.steeredMessageIds,
onResendAsPrompt: resendFeedbackAsPrompt,
})
// Composer "insert into current turn" (native steering only). Rethrows —
Expand Down
1 change: 1 addition & 0 deletions src/components/message/sub-agent-session-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ function makeConnState(overrides: Partial<ConnectionState>): ConnectionState {
parentConnectionId: "p1",
isViewer: false,
pendingUserMessage: null,
steeredMessageIds: [],
configStale: false,
configStaleKind: null,
configStaleDismissed: false,
Expand Down
150 changes: 150 additions & 0 deletions src/contexts/acp-connections-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4066,3 +4066,153 @@ describe("live surfaces that are not tabs", () => {
}
})
})

/**
* A message the user sends mid-turn over the native `_session/steering`
* channel is spliced into the live turn, so the transcript can render it as a
* user turn between the two halves of the reply.
*
* The discriminator is that the note is ALREADY `delivered` when it is
* submitted: `FeedbackItem::new_delivered` (src-tauri/src/acp/feedback.rs) has
* exactly one caller, the native push path, and it exists precisely because
* the adapter has already consumed the text by then. A `pending` note is the
* cooperative `check_user_feedback` pull channel, which the agent reads as a
* tool result and never as a user message.
*/
describe("AcpConnectionsProvider mid-turn steering messages", () => {
async function connectOwner(): Promise<AttachHandlers> {
h.acpFindConnectionForConversation.mockResolvedValue(null)
await mountProvider()
await act(async () => {
await h.actions!.connect(TAB, "claude_code", "/tmp/x", "sess-1", 42)
})
return latestAttachHandlers()
}

function conn() {
return h.store!.getConnection(TAB)!
}

function steeringBlocks() {
return (conn().liveMessage?.content ?? []).filter(
(b) => b.type === "steering"
)
}

function submitted(
seq: number,
id: string,
text: string,
status: "pending" | "delivered"
): EventEnvelope {
return {
seq,
connection_id: "spawned-conn",
type: "feedback_submitted",
item: { id, text, created_at: "2026-06-07T00:00:00Z", status },
} as unknown as EventEnvelope
}

it("splices a delivered note into the running turn and records the adoption", async () => {
const handlers = await connectOwner()
emitAcpEvent(handlers, {
seq: 1,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
emitAcpEvent(handlers, {
seq: 2,
connection_id: "spawned-conn",
type: "content_delta",
text: "half one",
} as unknown as EventEnvelope)
emitAcpEvent(handlers, submitted(3, "n1", "use the other API", "delivered"))

expect(steeringBlocks()).toEqual([
{ type: "steering", id: "n1", text: "use the other API" },
])
expect(conn().steeredMessageIds).toEqual(["n1"])
})

it("ignores a pending note - the pull channel is not a user message", async () => {
const handlers = await connectOwner()
emitAcpEvent(handlers, {
seq: 1,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
emitAcpEvent(handlers, submitted(2, "n1", "waiting note", "pending"))

expect(steeringBlocks()).toEqual([])
expect(conn().steeredMessageIds).toEqual([])
})

it("is idempotent - the submit broadcast reaches the sender too", async () => {
const handlers = await connectOwner()
emitAcpEvent(handlers, {
seq: 1,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
emitAcpEvent(handlers, submitted(2, "n1", "same note", "delivered"))
emitAcpEvent(handlers, submitted(3, "n1", "same note", "delivered"))

expect(steeringBlocks()).toHaveLength(1)
expect(conn().steeredMessageIds).toEqual(["n1"])
})

it("refuses a note that arrives with no turn running", async () => {
// The native submit is recorded ungated on the backend, so a note can land
// just after the turn settled. There is nothing to split then, and
// appending would graft it onto the finished turn. The note keeps its
// strip instead (it is absent from `steeredMessageIds`), and the agent
// recorded it either way, so a reload still shows it.
const handlers = await connectOwner()
emitAcpEvent(handlers, {
seq: 1,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
emitAcpEvent(handlers, {
seq: 2,
connection_id: "spawned-conn",
type: "status_changed",
status: "connected",
})
emitAcpEvent(handlers, submitted(3, "n1", "too late", "delivered"))

expect(steeringBlocks()).toEqual([])
expect(conn().steeredMessageIds).toEqual([])
})

it("starts each turn with no adoptions carried over", async () => {
const handlers = await connectOwner()
emitAcpEvent(handlers, {
seq: 1,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
emitAcpEvent(handlers, submitted(2, "n1", "first turn", "delivered"))
expect(conn().steeredMessageIds).toEqual(["n1"])

emitAcpEvent(handlers, {
seq: 3,
connection_id: "spawned-conn",
type: "status_changed",
status: "connected",
})
emitAcpEvent(handlers, {
seq: 4,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
expect(conn().steeredMessageIds).toEqual([])
expect(steeringBlocks()).toEqual([])
})
})
88 changes: 88 additions & 0 deletions src/contexts/acp-connections-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ export type LiveContentBlock =
| { type: "thinking"; text: string; parentToolUseId?: string }
| { type: "plan"; entries: PlanEntryInfo[] }
| { type: "tool_call"; info: ToolCallInfo }
/**
* A message the user sent WHILE this turn was running, injected into it via
* the native `_session/steering` channel. Not agent output: it marks the
* point in the stream where the user interrupted, so
* `buildStreamingTurnsFromLiveMessage` can close the assistant turn here,
* render the message as its own user turn, and start the reply to it as a
* new turn. Mirrors what the transcript projection already does with a
* mid-turn `user_message_chunk` (see `parsers/acp_native.rs`), so the live
* view and a reload agree. `id` is the feedback note id.
*/
| { type: "steering"; id: string; text: string }

export interface LiveMessage {
id: string
Expand Down Expand Up @@ -225,6 +236,19 @@ export interface ConnectionState {
* event or a snapshot's `pending_user_message`. A VIEWER mirrors this into
* the runtime as a synthesized user turn; `null` outside an active turn. */
pendingUserMessage: PendingUserMessage | null
/**
* Feedback-note ids whose text this turn's `liveMessage` adopted as a
* `steering` block, i.e. the mid-turn messages now rendered as user turns in
* the transcript. The notes list above the composer reads this to drop their
* strips: one message shows in exactly one place. Reset with `liveMessage`
* at the start of every turn.
*
* The reducer is the single decider — a note it could NOT adopt (it arrived
* out of turn) is absent here, so its strip stays. Deriving this in the
* notes hook instead would race the reducer's own view of the status and
* could leave a message showing nowhere at all.
*/
steeredMessageIds: string[]
pendingQuestion: PendingQuestion | null
/** Awaiting-answer multiple-choice `ask_user_question` (the codeg-mcp blocking
* tool). Set from a `question_request` event or a snapshot's
Expand Down Expand Up @@ -591,6 +615,12 @@ type Action =
contextKey: string
entries: PlanEntryInfo[]
}
| {
type: "STEERING_MESSAGE"
contextKey: string
id: string
text: string
}
| {
type: "CLAUDE_API_RETRY"
contextKey: string
Expand Down Expand Up @@ -1122,6 +1152,10 @@ function ensureLiveMessage(prev: LiveMessage | null): LiveMessage {
}
}

/** Shared empty `steeredMessageIds`, so a turn that steers nothing (almost all
* of them) keeps a stable reference through `connRenderEqual`. */
const EMPTY_STEERED_MESSAGE_IDS: string[] = []

/** Last time an out-of-turn drop was logged — module-level sampling clock. */
let lastOutOfTurnDropLogAt = 0

Expand Down Expand Up @@ -1320,6 +1354,7 @@ function connectionsReducer(
liveMessage: null,
pendingPermission: null,
pendingUserMessage: null,
steeredMessageIds: EMPTY_STEERED_MESSAGE_IDS,
pendingQuestion: null,
pendingAskQuestion: null,
pendingPlanApproval: null,
Expand Down Expand Up @@ -1378,6 +1413,7 @@ function connectionsReducer(
liveMessage: null,
pendingPermission: null,
pendingUserMessage: null,
steeredMessageIds: EMPTY_STEERED_MESSAGE_IDS,
pendingQuestion: null,
pendingAskQuestion: null,
pendingPlanApproval: null,
Expand Down Expand Up @@ -1572,6 +1608,8 @@ function connectionsReducer(
updated.pendingQuestion = null
updated.claudeApiRetry = null
updated.error = null
// Steering adoptions belong to the turn whose stream they split.
updated.steeredMessageIds = EMPTY_STEERED_MESSAGE_IDS
// Starting a prompt past an active AIR failure acknowledges it —
// settle EVERYTHING (watermarks retained). A failure that is still
// real re-arms via a higher revision on the same id.
Expand Down Expand Up @@ -2347,6 +2385,34 @@ function connectionsReducer(
return next
}

case "STEERING_MESSAGE": {
const conn = state.get(action.contextKey)
if (!conn) return state
// Same out-of-turn guard as PLAN_UPDATE / TOOL_CALL / streaming deltas:
// there is no running turn to split, and appending would graft the
// message onto the PREVIOUS turn's completed liveMessage. The note keeps
// its strip in that case (it is absent from `steeredMessageIds`), and
// the agent recorded it either way, so a reload still shows it.
if (conn.status !== "prompting") return state
// Idempotent by note id: the submit broadcast reaches every attached
// client, and one client is also the sender.
if (conn.steeredMessageIds.includes(action.id)) return state
const prev = ensureLiveMessage(conn.liveMessage)
const next = new Map(state)
next.set(action.contextKey, {
...conn,
liveMessage: {
...prev,
content: [
...prev.content,
{ type: "steering" as const, id: action.id, text: action.text },
],
},
steeredMessageIds: [...conn.steeredMessageIds, action.id],
})
return next
}

case "CLAUDE_API_RETRY": {
const conn = state.get(action.contextKey)
if (!conn) return state
Expand Down Expand Up @@ -3529,6 +3595,28 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
})
scheduleToolCallUpdateFlush()
break
case "feedback_submitted": {
// A note that is ALREADY `delivered` when it is submitted was pushed
// into the running turn over the native `_session/steering` channel
// (`FeedbackItem::new_delivered` is that path's only producer). The
// agent has the text as a user message, so the transcript shows it
// as one: it closes the assistant turn at this point in the stream
// and the reply to it starts a new turn.
//
// A `pending` note is the cooperative `check_user_feedback` pull
// channel — the agent has not read it, and when it does it arrives
// as a tool result, never a user message. Those stay in the notes
// list above the composer, which is where a reload leaves them too.
if (e.item.status !== "delivered") break
flushStreamingQueue()
dispatch({
type: "STEERING_MESSAGE",
contextKey,
id: e.item.id,
text: e.item.text,
})
break
}
case "permission_resolved":
// Backend signals a permission was answered (this window's local
// respondPermission, a sibling window, a server-mode peer, or
Expand Down
Loading
Loading