diff --git a/.changeset/web-steer-capslock-fix.md b/.changeset/web-steer-capslock-fix.md new file mode 100644 index 0000000000..46fa41b2d0 --- /dev/null +++ b/.changeset/web-steer-capslock-fix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix Ctrl+S steering not triggering when CapsLock is on. diff --git a/.changeset/web-steer-queued-messages.md b/.changeset/web-steer-queued-messages.md new file mode 100644 index 0000000000..db2deac77a --- /dev/null +++ b/.changeset/web-steer-queued-messages.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Add "Steer now" buttons to the queued-message list to inject queued messages into the running turn — one message at a time or the whole queue from the queue header. Click the bolt button next to a queued message to steer it. diff --git a/.changeset/web-steer-telemetry.md b/.changeset/web-steer-telemetry.md new file mode 100644 index 0000000000..c8495a6630 --- /dev/null +++ b/.changeset/web-steer-telemetry.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Count steered messages sent from the web UI in usage telemetry, the same way steers from the TUI are counted. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5708bcc9bf..299e6ae591 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -837,6 +837,7 @@ function openPr(url: string): void { @interrupt="client.abortCurrentPrompt()" @unqueue="handleUnqueue" @edit-queued="handleEditQueued" + @steer-queued="client.steerQueuedPrompt($event)" @reorder-queue="handleReorderQueue" @set-permission="client.setPermission($event)" @set-thinking="client.setThinking($event)" diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 79ad6c5d19..f71b1ee5fe 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -15,6 +15,7 @@ import AttachmentChip from './AttachmentChip.vue'; import MoonSpinner from '../ui/MoonSpinner.vue'; import Spinner from '../ui/Spinner.vue'; import Icon from '../ui/Icon.vue'; +import Button from '../ui/Button.vue'; import Tooltip from '../ui/Tooltip.vue'; import { useConfirmDialog } from '../../composables/useConfirmDialog'; import { copyTextToClipboard } from '../../lib/clipboard'; @@ -213,6 +214,11 @@ const emit = defineEmits<{ unqueue: [index: number]; /** Load a queued message back into the composer for editing (and dequeue it). */ editQueued: [index: number]; + /** Steer the whole queue into the running turn now (like Ctrl+S, but the + * live composer draft stays behind). */ + steerQueue: []; + /** Steer one queued message (by index) into the running turn now. */ + steerQueued: [index: number]; /** Drag-to-reorder a queued message within the active session's queue. */ reorderQueue: [payload: { from: number; to: number }]; }>(); @@ -689,6 +695,16 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): {{ t('composer.queueLabel') }} · {{ queued.length }} {{ t('composer.queueAutoDrain') }} +
{{ t('composer.queueNext') }} #{{ qi + 1 }} +
diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 03a541fe0a..87b670d768 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -115,7 +115,7 @@ const startingFirstPromptWorkspaces = reactive(new Set()); */ const promptGenerationBySession = new Map(); const pendingLocalTurnStarts = new Map>(); -const afterLocalTurnsSettled = new Map void>(); +const afterLocalTurnsSettled = new Map void>>(); let nextLocalTurnToken = 0; /** @@ -134,6 +134,124 @@ function nextQueueEntryId(): string { return `${Date.now().toString(36)}-${queueEntryCounter}`; } +/** + * Per-session serialized operation queue for every queue-mutating prompt + * submission: per-entry steers, whole-queue steers, and automatic queue + * drains. Running them through one chain guarantees at most one submission + * per session is in flight at a time, so removals, restores, and drains + * always interleave in the order the operations were issued and the queue's + * FIFO order survives any interleaving of the three. + */ +const sessionOpQueue = new Map>(); + +interface SessionArchiveBarrier { + promise: Promise; + resolve: () => void; +} + +/** + * Archive starts before the daemon has confirmed whether the session was + * forgotten. Queue-mutating work must pause in that window: success invalidates + * its incarnation, while failure releases it against the still-live session. + */ +const sessionArchiveBarriers = new Map(); + +interface SessionOperation { + sid: string; + incarnation: number; +} + +/** + * A queue drain is reserved synchronously, before its serialized operation can + * start on the promise microtask queue. This closes the window where another + * send or turn-end callback could observe an idle session and schedule a second + * drain before submitPromptInternal marks the first prompt in flight. + * + * Store the owning operation, rather than a boolean, so a late completion from + * an archived incarnation cannot clear a reservation created after restore. + */ +const sessionQueueDrainReservations = new Map(); + +/** + * Monotonic session incarnation retained across teardown. A session id can be + * restored after archive, so presence alone cannot distinguish late work from + * the old instance. Never delete these markers: resetting to the default would + * let an old incarnation compare equal again after an archive/restore ABA. + */ +const sessionIncarnation = new Map(); +let nextSessionIncarnation = 0; + +function captureSessionOperation(sid: string): SessionOperation { + return { sid, incarnation: sessionIncarnation.get(sid) ?? 0 }; +} + +function hasCurrentIncarnation(operation: SessionOperation): boolean { + return operation.incarnation === (sessionIncarnation.get(operation.sid) ?? 0); +} + +function invalidateSessionOperations(sid: string): void { + nextSessionIncarnation += 1; + sessionIncarnation.set(sid, nextSessionIncarnation); +} + +function reserveSessionQueueDrain(operation: SessionOperation): boolean { + if (!hasCurrentIncarnation(operation) || sessionQueueDrainReservations.has(operation.sid)) { + return false; + } + sessionQueueDrainReservations.set(operation.sid, operation); + return true; +} + +function releaseSessionQueueDrain(operation: SessionOperation): void { + if (sessionQueueDrainReservations.get(operation.sid) === operation) { + sessionQueueDrainReservations.delete(operation.sid); + } +} + +function beginSessionArchive(sid: string): SessionArchiveBarrier | undefined { + if (sessionArchiveBarriers.has(sid)) return undefined; + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + const barrier = { promise, resolve }; + sessionArchiveBarriers.set(sid, barrier); + return barrier; +} + +function sessionArchiveBarrierPromise(sid: string): Promise | undefined { + return sessionArchiveBarriers.get(sid)?.promise; +} + +function endSessionArchive(sid: string, barrier: SessionArchiveBarrier): void { + if (sessionArchiveBarriers.get(sid) === barrier) { + sessionArchiveBarriers.delete(sid); + } + barrier.resolve(); +} + +/** Serialize `op` behind every previously queued operation for the session. + * Rejections are absorbed for SEQUENCING only — the chain keeps later ops + * running — while the caller still receives the op's original error. */ +function enqueueSessionOp( + operation: SessionOperation, + op: () => Promise, +): Promise { + const { sid } = operation; + const chain = sessionOpQueue.get(sid) ?? Promise.resolve(); + const run = chain.then(async () => { + await sessionArchiveBarrierPromise(sid); + if (!hasCurrentIncarnation(operation)) return; + await op(); + }); + const tail = run.catch(() => undefined); + sessionOpQueue.set(sid, tail); + void tail.finally(() => { + if (sessionOpQueue.get(sid) === tail) sessionOpQueue.delete(sid); + }); + return run; +} + export interface LocalTurnStartState { generation: number; pending: boolean; @@ -167,17 +285,33 @@ export function settleLocalTurn(sid: string, token: number): void { pending.delete(token); if (pending.size > 0) return; pendingLocalTurnStarts.delete(sid); - const callback = afterLocalTurnsSettled.get(sid); + const callbacks = afterLocalTurnsSettled.get(sid); afterLocalTurnsSettled.delete(sid); - callback?.(); + if (callbacks !== undefined) { + // Isolate each callback so one throwing registrant can't skip the rest + // (e.g. a deferred drain behind a snapshot retry). + for (const callback of callbacks) { + try { + callback(); + } catch { + // Registrants are fire-and-forget; a failure must not block the others. + } + } + } } /** Drop lifecycle state with the rest of a forgotten session. */ export function forgetLocalTurnState(sid: string): void { + invalidateSessionOperations(sid); promptGenerationBySession.delete(sid); pendingLocalTurnStarts.delete(sid); afterLocalTurnsSettled.delete(sid); queueFlushFailures.delete(sid); + sessionQueueDrainReservations.delete(sid); + // Safe to drop the chain itself: queued ops are no-ops under the bumped + // incarnation, and a fresh incarnation must never queue behind an op that + // may never settle (e.g. a wedged request from the archived instance). + sessionOpQueue.delete(sid); } /** Whether a snapshot request can still be applied without overwriting a @@ -186,13 +320,18 @@ export function isLocalTurnSnapshotCurrent(sid: string, atRequest: LocalTurnStar return !atRequest.pending && atRequest.generation === (promptGenerationBySession.get(sid) ?? 0); } -/** Coalesce a skipped snapshot into one retry after local turn-start requests settle. */ +/** Run `callback` once every in-flight local turn-start for the session has + * settled (immediately when none are pending). Multiple registrants — e.g. a + * deferred queue drain and a stale-snapshot retry landing in the same + * window — are all preserved and run in registration order. */ export function afterLocalTurnStartsSettle(sid: string, callback: () => void): void { if ((pendingLocalTurnStarts.get(sid)?.size ?? 0) === 0) { callback(); return; } - afterLocalTurnsSettled.set(sid, callback); + const callbacks = afterLocalTurnsSettled.get(sid) ?? new Set<() => void>(); + callbacks.add(callback); + afterLocalTurnsSettled.set(sid, callbacks); } type SyncSessionResult = 'ok' | 'not-found' | 'failed'; @@ -315,6 +454,13 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } = deps; let exportInFlight = false; + function isSessionOperationCurrent(operation: SessionOperation): boolean { + return ( + hasCurrentIncarnation(operation) && + rawState.sessions.some((session) => session.id === operation.sid) + ); + } + async function loadOlderMessages(sessionId: string): Promise { if (rawState.messagesLoadingMoreBySession[sessionId]) return; const current = rawState.messagesBySession[sessionId]; @@ -1456,14 +1602,26 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta sid: string, text: string, attachments?: PromptAttachment[], - ): Promise<'ok' | 'rejected' | 'uncertain'> { - // Mark this session as having a prompt in flight BEFORE any await, so a racing - // sendPrompt sees it and enqueues. Cleared when the main turn ends (or the - // prompt dies without one). beginLocalTurn also bumps the snapshot generation - // and marks the submit pending, so a racing terminal snapshot can't clear - // this prompt (see handleSessionSnapshot). + operation: SessionOperation = captureSessionOperation(sid), + ): Promise<'ok' | 'rejected' | 'uncertain' | 'cancelled'> { + // Mark this session as having a prompt in flight BEFORE any await — + // including the archive-barrier wait, so two sends racing an in-flight + // archive cannot both proceed as concurrent submissions when the archive + // fails. Cleared when the main turn ends (or the prompt dies without + // one). beginLocalTurn also bumps the snapshot generation and marks the + // submit pending, so a racing terminal snapshot can't clear this prompt + // (see handleSessionSnapshot). const localTurnToken = beginLocalTurn(sid); rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: true }; + const archiveBarrier = sessionArchiveBarrierPromise(sid); + if (archiveBarrier !== undefined) await archiveBarrier; + if (!hasCurrentIncarnation(operation)) { + // An archive completed while we waited and forgot the session — release + // our own marks and cancel (the session's state is already discarded). + rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false }; + settleLocalTurn(sid, localTurnToken); + return 'cancelled'; + } const tempId = nextOptimisticMsgId(); try { const api = getKimiWebApi(); @@ -1518,7 +1676,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (goalMode && text) { try { await api.updateSession(sid, { goalObjective: text.trim() }); + if (!hasCurrentIncarnation(operation)) return 'cancelled'; } catch (err) { + if (!hasCurrentIncarnation(operation)) return 'cancelled'; pushOperationFailure('createGoal', err, { sessionId: sid }); rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false }; updateSessionMessages(sid, (msgs) => @@ -1528,6 +1688,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } + const thinking = + (await modelProvider.resolveThinkingForPrompt(sid, model)) ?? rawState.thinking; + await sessionArchiveBarrierPromise(sid); + if (!hasCurrentIncarnation(operation)) return 'cancelled'; + const result = await api.submitPrompt(sid, { content, model, @@ -1537,11 +1702,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // tracks whatever session the user is looking at now: a queue drain for // a background session would otherwise submit the level of the session // the user switched to since enqueueing. - thinking: (await modelProvider.resolveThinkingForPrompt(sid, model)) ?? rawState.thinking, + thinking, permissionMode: rawState.permission, planMode, swarmMode, }); + if (!hasCurrentIncarnation(operation)) return 'cancelled'; // Goal mode is a one-shot flag: consumed by this send, then cleared. if (goalMode) { @@ -1579,6 +1745,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // daemon's auto-title, so we let the daemon own it. return 'ok'; } catch (err) { + if (!hasCurrentIncarnation(operation)) return 'cancelled'; // Submit failed — clear the in-flight flag so the next prompt isn't stuck // queued forever (turn.ended will never arrive), and roll back the // optimistic user message so the transcript doesn't show a delivered- @@ -1605,8 +1772,13 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // If the session is not idle OR a prompt is already in flight (submitted but // the WS turn.started hasn't arrived yet), enqueue instead of submitting // directly. The in-flight flag closes the window where two rapid prompts - // would both submit and race. - if (activity.value !== 'idle' || rawState.inFlightBySession[sid]) { + // would both submit and race. A reserved queue drain (scheduled but not yet + // submitting, e.g. parked behind a pending steer) counts as busy too. + if ( + activity.value !== 'idle' || + rawState.inFlightBySession[sid] || + sessionQueueDrainReservations.has(sid) + ) { enqueue(text, attachments); return; } @@ -1617,8 +1789,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // enqueue this prompt behind them and flush the head now — the flush // re-arms the in-flight flag, and each later turn end drains the next // entry. Submitting directly here would jump the queue AND leave the - // stuck entries without a flush driver. - if ((rawState.queuedBySession[sid]?.length ?? 0) > 0) { + // stuck entries without a flush driver. A pending local turn-start (e.g. + // a steer submit) takes the same path: the serialized drain submits + // behind it instead of racing a second concurrent POST, which could start + // its turn first and pull the older steered text into the wrong turn. + if ( + (rawState.queuedBySession[sid]?.length ?? 0) > 0 || + localTurnStartState(sid).pending + ) { enqueue(text, attachments); flushQueueHead(sid); return; @@ -1637,40 +1815,129 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta async function steerPrompt(text: string, attachments?: PromptAttachment[]): Promise { const sid = rawState.activeSessionId; if (!sid) return; - - // Merge queued texts (oldest first) + the live text, like the TUI does. - const queue = rawState.queuedBySession[sid] ?? []; - const parts: string[] = []; - const mergedAttachments: PromptAttachment[] = []; - for (const q of queue) { - const trimmed = q.text.trim(); - if (trimmed) parts.push(trimmed); - if (q.attachments?.length) mergedAttachments.push(...q.attachments); - } + const operation = captureSessionOperation(sid); const live = text.trim(); - if (live) parts.push(live); - if (attachments?.length) mergedAttachments.push(...attachments); - if (parts.length === 0 && mergedAttachments.length === 0) return; - if (queue.length > 0) { - rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: [] }; - } - const merged = parts.join('\n\n'); - - // Put back every entry that was merged into this steer when its submit - // fails, so the queued prompts aren't silently lost. Entries enqueued - // while the submit was in flight stay behind them. - const restoreQueue = (): void => { - if (queue.length === 0) return; + + // The merge runs inside the session's op queue (serialized with per-entry + // steers and drains), so the queue is read and cleared at execution time. + await enqueueSessionOp(operation, async () => { + if (!isSessionOperationCurrent(operation)) return; + // Merge queued texts (oldest first) + the live text, like the TUI does. + const queue = rawState.queuedBySession[sid] ?? []; + const parts: string[] = []; + const mergedAttachments: PromptAttachment[] = []; + for (const q of queue) { + const trimmed = q.text.trim(); + if (trimmed) parts.push(trimmed); + if (q.attachments?.length) mergedAttachments.push(...q.attachments); + } + if (live) parts.push(live); + if (attachments?.length) mergedAttachments.push(...attachments); + if (parts.length === 0 && mergedAttachments.length === 0) return; + if (queue.length > 0) { + rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: [] }; + } + const merged = parts.join('\n\n'); + + // Put back every entry that was merged into this steer when its submit + // fails, so the queued prompts aren't silently lost. Entries enqueued + // while the submit was in flight stay behind them. + const restoreQueue = (): void => { + if (queue.length === 0) return; + const current = rawState.queuedBySession[sid] ?? []; + rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: [...queue, ...current] }; + }; + + await steerContentInternal(sid, merged, mergedAttachments, restoreQueue, operation); + }).catch((err: unknown) => { + // steerContentInternal handles its own failures; surface an unexpected + // op-queue error instead of an unhandled rejection. + pushOperationFailure('steer', err, { sessionId: sid }); + }); + } + + /** + * steerQueuedPrompt() — steer a SINGLE queued entry into the running turn, + * leaving the rest of the queue untouched. Same submit+steer flow (and the + * same restore-on-rejection rule) as steerPrompt, scoped to one entry. + * Serialized with every other queue-mutating operation (see sessionOpQueue); + * the removal re-resolves the clicked entry by identity at execution time + * because reorders and restores may have moved it since the click. + */ + async function steerQueuedPrompt(index: number): Promise { + const sid = rawState.activeSessionId; + if (!sid) return; + const operation = captureSessionOperation(sid); + const entry = (rawState.queuedBySession[sid] ?? [])[index]; + if (!entry) return; + const text = entry.text.trim(); + const entryAttachments = entry.attachments ?? []; + if (!text && entryAttachments.length === 0) return; + + await enqueueSessionOp(operation, async () => { + if (!isSessionOperationCurrent(operation)) return; const current = rawState.queuedBySession[sid] ?? []; - rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: [...queue, ...current] }; - }; + const currentIndex = current.indexOf(entry); + // Edited, unqueued, or drained while an earlier op was in flight. + if (currentIndex === -1) return; + rawState.queuedBySession = { + ...rawState.queuedBySession, + [sid]: current.filter((_, i) => i !== currentIndex), + }; + + // Operations are serialized, so the removal slot is stable with respect + // to other steers. If the user reorders the visible queue while the + // request is pending, put the rejected entry back into that same slot in + // the latest order instead of anchoring it ahead of a moved successor. + const restoreQueue = (): void => { + const latest = rawState.queuedBySession[sid] ?? []; + if (latest.includes(entry)) return; + const next = [...latest]; + next.splice(Math.min(currentIndex, next.length), 0, entry); + rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: next }; + }; + await steerContentInternal(sid, text, entryAttachments, restoreQueue, operation); + }).catch((err: unknown) => { + // steerContentInternal handles its own failures; surface an unexpected + // op-queue error instead of an unhandled rejection. + pushOperationFailure('steer', err, { sessionId: sid }); + }); + } + + /** + * Shared steer tail: idle fallback, optimistic echo, submit (parks behind the + * active prompt) then POST /prompts:steer. `restoreQueue` undoes the caller's + * queue removal on a definitive daemon rejection. + */ + async function steerContentInternal( + sid: string, + merged: string, + mergedAttachments: PromptAttachment[], + restoreQueue: () => void, + operation: SessionOperation, + ): Promise { + if (!isSessionOperationCurrent(operation)) return; + // Same guard as the queue drain: if the session was forgotten (e.g. + // archived) while the submit was pending, its queue was already discarded + // — restoring would resurrect a stale message that could drain later. + const restoreIfSessionKnown = (): void => { + if (rawState.sessions.some((s) => s.id === sid)) restoreQueue(); + }; + // Liveness of THIS session's main conversation — not the active view's + // `activity`, which follows whichever session the user has selected (a + // serialized per-entry steer can fire after a session switch). + const turnRunning = + (rawState.turnActiveBySession[sid] ?? false) || + rawState.inFlightBySession[sid] === true || + (rawState.sessions.find((session) => session.id === sid)?.mainTurnActive ?? false); // Idle and nothing in flight — there is no turn to steer into; normal send. - if (activity.value === 'idle' && !rawState.inFlightBySession[sid]) { - const outcome = await submitPromptInternal(sid, merged, mergedAttachments); + if (!turnRunning) { + const outcome = await submitPromptInternal(sid, merged, mergedAttachments, operation); + if (!isSessionOperationCurrent(operation)) return; // Same never-duplicate rule as the running-path catch below: restore // the merged entries only on a definitive rejection. - if (outcome === 'rejected') restoreQueue(); + if (outcome === 'rejected') restoreIfSessionKnown(); return; } @@ -1708,21 +1975,28 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta (promptSession?.model && promptSession.model.length > 0 ? promptSession.model : rawState.defaultModel) ?? undefined; + const thinking = + (await modelProvider.resolveThinkingForPrompt(sid, model)) ?? rawState.thinking; + await sessionArchiveBarrierPromise(sid); + if (!isSessionOperationCurrent(operation)) return; + const result = await api.submitPrompt(sid, { content, model, // Resolved against this prompt's own session + model, same as a normal // send (see submitPromptInternal). - thinking: (await modelProvider.resolveThinkingForPrompt(sid, model)) ?? rawState.thinking, + thinking, permissionMode: rawState.permission, planMode: rawState.planModeBySession[sid] ?? false, swarmMode: rawState.swarmModeBySession[sid] ?? false, }); + await sessionArchiveBarrierPromise(sid); + if (!isSessionOperationCurrent(operation)) return; - // Stamp the real prompt_id onto the optimistic echo. Unlike a normal send, - // a steered prompt IS echoed back by the daemon as a messageCreated user - // event; matching that echo by prompt_id (instead of content) is what keeps - // an image steer from rendering two user bubbles. + // Stamp the real prompt_id onto the optimistic echo. kap-server emits + // no messageCreated event for a steered prompt, but the stamp lets any + // later snapshot or resync match the persisted message by id instead of + // falling back to looser content heuristics. updateSessionMessages(sid, (msgs) => { const idx = msgs.findIndex((m) => m.id === tempId); if (idx === -1) return msgs; @@ -1746,6 +2020,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // the parked prompt as its own turn. Nothing to roll back. } } catch (err) { + if (!isSessionOperationCurrent(operation)) return; // Submit failed: drop the optimistic echo so the transcript doesn't show // a delivered-looking message the daemon never received. updateSessionMessages(sid, (msgs) => msgs.filter((m) => m.id !== tempId)); @@ -1755,7 +2030,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // already be queued server-side; re-queueing the originals would // duplicate it (the exact ghost-send behavior this change exists to // prevent). The failure toast below tells the user what happened. - if (isDaemonApiError(err)) restoreQueue(); + if (isDaemonApiError(err)) restoreIfSessionKnown(); pushOperationFailure('steer', err, { sessionId: sid }); } finally { settleLocalTurn(sid, localTurnToken); @@ -1801,54 +2076,93 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta * queue. The budget is tracked PER ENTRY (by id), so removing or * reordering the head resets it for the next entry. Every failure is * already surfaced via pushOperationFailure. + * + * The submission runs inside the session's op queue (see sessionOpQueue), + * serialized with steers and other drains, so a drain can never submit + * concurrently with a steer that is still in flight. */ function flushQueueHead(sid: string): void { + const operation = captureSessionOperation(sid); + flushQueueHeadForOperation(operation); + } + + function flushQueueHeadForOperation(operation: SessionOperation): void { + if ((rawState.queuedBySession[operation.sid]?.length ?? 0) === 0) return; + if (!reserveSessionQueueDrain(operation)) return; + + const enqueueReservedDrain = () => { + // submitPromptInternal reports its own failures; this catch is the + // last-resort surface for an unexpected error inside the drain itself, + // now that op-queue rejections propagate to the caller. + void enqueueSessionOp(operation, () => flushQueueHeadNow(operation)) + .catch((err: unknown) => { + if (!isSessionOperationCurrent(operation)) return; + pushOperationFailure('queueFlush', err, { sessionId: operation.sid }); + }) + .finally(() => { + releaseSessionQueueDrain(operation); + }); + }; + + // Reserve before deferring behind a local turn start. Otherwise a same-tick + // send can schedule another drain while this one is waiting to enter the op + // queue. + if (localTurnStartState(operation.sid).pending) { + afterLocalTurnStartsSettle(operation.sid, enqueueReservedDrain); + } else { + enqueueReservedDrain(); + } + } + + async function flushQueueHeadNow(operation: SessionOperation): Promise { + if (!isSessionOperationCurrent(operation)) return; + const { sid } = operation; const [next, ...rest] = rawState.queuedBySession[sid] ?? []; if (next === undefined) return; rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: rest }; - void submitPromptInternal(sid, next.text, next.attachments).then((outcome) => { - if (outcome === 'ok') { - queueFlushFailures.delete(sid); - return; - } - // Ambiguous failure: the daemon may have accepted the prompt and lost - // the response — the entry is dropped (the failure was already toasted) - // rather than re-queued and possibly submitted twice. - if (outcome === 'uncertain') { - queueFlushFailures.delete(sid); - return; - } - // Definitively rejected below this point. If the session was forgotten - // (e.g. archived) while the submit was pending, its queue was already - // discarded — don't resurrect it. - if (!rawState.sessions.some((s) => s.id === sid)) { - queueFlushFailures.delete(sid); - return; - } - // Per-entry budget: a different head (removed/reordered since) starts - // fresh instead of inheriting the previous entry's failures. - const key = next.id ?? next.text; - const previous = queueFlushFailures.get(sid); - const count = previous !== undefined && previous.key === key ? previous.count + 1 : 1; - if (count >= MAX_QUEUE_FLUSH_FAILURES) { - queueFlushFailures.delete(sid); - // Advance the queue instead of stranding the entries behind the - // dropped head: the failed submit produced no turn, so nothing else - // will drive the next entry until the user sends again. The new head - // carries its own budget, so a poisoned successor just goes through - // the same retry cycle. - if ((rawState.queuedBySession[sid]?.length ?? 0) > 0) { - flushQueueHead(sid); - } - return; + const outcome = await submitPromptInternal(sid, next.text, next.attachments, operation); + if (!isSessionOperationCurrent(operation) || outcome === 'cancelled') return; + if (outcome === 'ok') { + queueFlushFailures.delete(sid); + return; + } + // Ambiguous failure: the daemon may have accepted the prompt and lost + // the response — the entry is dropped (the failure was already toasted) + // rather than re-queued and possibly submitted twice. + if (outcome === 'uncertain') { + queueFlushFailures.delete(sid); + return; + } + // Definitively rejected below this point. If the session was forgotten + // (e.g. archived) while the submit was pending, its queue was already + // discarded — don't resurrect it. + if (!rawState.sessions.some((s) => s.id === sid)) { + queueFlushFailures.delete(sid); + return; + } + // Per-entry budget: a different head (removed/reordered since) starts + // fresh instead of inheriting the previous entry's failures. + const key = next.id ?? next.text; + const previous = queueFlushFailures.get(sid); + const count = previous !== undefined && previous.key === key ? previous.count + 1 : 1; + if (count >= MAX_QUEUE_FLUSH_FAILURES) { + queueFlushFailures.delete(sid); + // Advance the queue instead of stranding the entries behind the + // dropped head: the failed submit produced no turn, so nothing else + // will drive the next entry until the user sends again. The new head + // carries its own budget, so a poisoned successor just goes through + // the same retry cycle. + if ((rawState.queuedBySession[sid]?.length ?? 0) > 0) { + await flushQueueHeadNow(operation); } - queueFlushFailures.set(sid, { key, count }); - const current = rawState.queuedBySession[sid] ?? []; - rawState.queuedBySession = { - ...rawState.queuedBySession, - [sid]: [next, ...current], - }; - }); + return; + } + queueFlushFailures.set(sid, { key, count }); + const current = rawState.queuedBySession[sid] ?? []; + rawState.queuedBySession = { + ...rawState.queuedBySession, + [sid]: [next, ...current], + }; } /** @@ -1888,7 +2202,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const mayDrain = wasInFlight || opts?.turnWasActive === true || (rawState.turnActiveBySession[sid] ?? false); if (mayDrain) { - flushQueueHead(sid); + const operation = captureSessionOperation(sid); + flushQueueHeadForOperation(operation); } return wasInFlight; @@ -2369,6 +2684,16 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** Archive a session — calls API, persists the archive flag, removes locally, picks another active session or none */ async function archiveSession(id: string): Promise { + // Another archive for this session is already in flight: wait for it, + // then run our own attempt — a second click is a retry intent, not a + // dedupe, when the first attempt failed. + const existing = sessionArchiveBarrierPromise(id); + if (existing !== undefined) await existing; + // The first attempt may already have archived (and forgotten) the + // session — retrying would re-POST against an archived session. + if (!rawState.sessions.some((s) => s.id === id)) return; + const barrier = beginSessionArchive(id); + if (barrier === undefined) return; // a concurrent caller beat us to it try { const api = getKimiWebApi(); await api.archiveSession(id); @@ -2391,6 +2716,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } catch (err) { pushOperationFailure('archiveSession', err, { sessionId: id }); + } finally { + endSessionArchive(id, barrier); } } @@ -2786,6 +3113,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta handleSessionSnapshot, sendPrompt, steerPrompt, + steerQueuedPrompt, uploadImage, enqueue, unqueue, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index f6e3f6a694..03df7c8abd 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2873,6 +2873,7 @@ export function useKimiWebClient() { sendPrompt: workspaceState.sendPrompt, steerPrompt: workspaceState.steerPrompt, + steerQueuedPrompt: workspaceState.steerQueuedPrompt, // Side chat (BTW side-channel agent) sideChatVisible: sideChat.sideChatVisible, sideChatSessionId: sideChat.sideChatSessionId, diff --git a/apps/kimi-web/src/i18n/locales/en/composer.ts b/apps/kimi-web/src/i18n/locales/en/composer.ts index 5f566c6729..6d4b237fcb 100644 --- a/apps/kimi-web/src/i18n/locales/en/composer.ts +++ b/apps/kimi-web/src/i18n/locales/en/composer.ts @@ -6,6 +6,9 @@ export default { starting: 'Sending…', queueAutoDrain: 'sends automatically when the current turn ends', queueNext: 'Up next', + queueSteerNow: 'Steer now', + queueSteerTitle: 'Inject the queued message(s) into the running turn (Ctrl+S)', + queueSteerOneTitle: 'Inject this message into the running turn', queueDragTitle: 'Drag to reorder', editQueued: 'Edit (load back into the input)', queuedAttachments: 'attachment ×{n}', diff --git a/apps/kimi-web/src/i18n/locales/zh/composer.ts b/apps/kimi-web/src/i18n/locales/zh/composer.ts index a0ef9f952b..85156de2af 100644 --- a/apps/kimi-web/src/i18n/locales/zh/composer.ts +++ b/apps/kimi-web/src/i18n/locales/zh/composer.ts @@ -6,6 +6,9 @@ export default { starting: '正在发送…', queueAutoDrain: '当前回合结束后自动逐条发送', queueNext: '下一条', + queueSteerNow: '立即插入', + queueSteerTitle: '把排队的消息立即插入运行中的回合(Ctrl+S)', + queueSteerOneTitle: '把这条消息立即插入运行中的回合', queueDragTitle: '拖拽排序', editQueued: '编辑(载入到输入框)', queuedAttachments: '附件 ×{n}', diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index d8871d9f8b..a1248cb27c 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -18,11 +18,14 @@ const apiMock = vi.hoisted(() => ({ abortPrompt: vi.fn(), abortSession: vi.fn(), addWorkspace: vi.fn(), + archiveSession: vi.fn(), updateWorkspace: vi.fn(), createSession: vi.fn(), exportSession: vi.fn(), + restoreSession: vi.fn(), updateSession: vi.fn(), submitPrompt: vi.fn(), + steerPrompts: vi.fn(), respondQuestion: vi.fn(), respondApproval: vi.fn(), dismissQuestion: vi.fn(), @@ -1576,6 +1579,12 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { beforeEach(() => { apiMock.submitPrompt.mockReset(); apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_new' }); + apiMock.steerPrompts.mockReset(); + apiMock.steerPrompts.mockResolvedValue({ steered: true, promptIds: ['prompt_new'] }); + apiMock.archiveSession.mockReset(); + apiMock.archiveSession.mockResolvedValue(undefined); + apiMock.restoreSession.mockReset(); + apiMock.restoreSession.mockResolvedValue(createSession()); // Module-level flush failure budget must not leak between tests. forgetLocalTurnState('sess_1'); }); @@ -1671,6 +1680,31 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { ]); }); + it('drains only one queued prompt when a send follows a turn finish in the same tick', async () => { + const state = createState(); + state.queuedBySession = { + sess_1: [ + { text: 'first queued', attachments: undefined }, + { text: 'second queued', attachments: undefined }, + ], + }; + const ws = useWorkspaceState( + state, + promptDeps({ activity: computed(() => 'idle') }), + ); + + ws.finishPromptLocal('sess_1', { turnWasActive: true }); + const send = ws.sendPrompt('new prompt'); + await send; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + expect(state.queuedBySession.sess_1?.map((entry) => entry.text)).toEqual([ + 'second queued', + 'new prompt', + ]); + }); + it('flushes the stuck queue head before the new prompt when sending while idle', async () => { const state = createState(); state.queuedBySession = { sess_1: [{ text: 'stuck queued', attachments: undefined }] }; @@ -1678,7 +1712,8 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { await ws.sendPrompt('next'); - expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + // The flush runs inside the session's op queue — one microtask behind. + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledOnce()); expect(apiMock.submitPrompt).toHaveBeenCalledWith( 'sess_1', expect.objectContaining({ content: [{ type: 'text', text: 'stuck queued' }] }), @@ -1688,6 +1723,28 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { ]); }); + it('drains only the stuck queue head when two idle sends happen in the same tick', async () => { + const state = createState(); + state.queuedBySession = { + sess_1: [{ text: 'stuck queued', attachments: undefined }], + }; + const ws = useWorkspaceState( + state, + promptDeps({ activity: computed(() => 'idle') }), + ); + + const firstSend = ws.sendPrompt('first new prompt'); + const secondSend = ws.sendPrompt('second new prompt'); + await Promise.all([firstSend, secondSend]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + expect(state.queuedBySession.sess_1?.map((entry) => entry.text)).toEqual([ + 'first new prompt', + 'second new prompt', + ]); + }); + it('re-queues a failed flush at the head and drops it after repeated failures', async () => { const state = createState(); state.queuedBySession = { sess_1: [{ text: 'first queued', attachments: undefined }] }; @@ -1733,6 +1790,75 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { ); }); + it('steers whole-queue text and attachments in visible order before live composer content', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'first', attachments: [{ fileId: 'img_q', kind: 'image' }] }, + { text: ' ', attachments: [{ fileId: 'video_q', kind: 'video' }] }, + { + text: 'third', + attachments: [{ + fileId: 'file_q', + kind: 'file', + name: 'notes.txt', + mediaType: 'text/plain', + size: 12, + }], + }, + ], + }; + apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_merged', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + await ws.steerPrompt('live', [{ fileId: 'img_live', kind: 'image' }]); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + content: [ + { type: 'text', text: 'first\n\nthird\n\nlive' }, + { type: 'image', source: { kind: 'file', fileId: 'img_q' } }, + { type: 'video', source: { kind: 'file', fileId: 'video_q' } }, + { + type: 'file', + fileId: 'file_q', + name: 'notes.txt', + mediaType: 'text/plain', + size: 12, + }, + { type: 'image', source: { kind: 'file', fileId: 'img_live' } }, + ], + }), + ); + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_merged']); + expect(state.queuedBySession.sess_1).toEqual([]); + }); + + it('normal-sends the whole queue when no main turn is running', async () => { + const state = createState(); + state.sessions = [{ ...createSession(), busy: false, mainTurnActive: false }]; + state.queuedBySession = { + sess_1: [ + { text: 'first', attachments: undefined }, + { text: 'second', attachments: undefined }, + ], + }; + const ws = useWorkspaceState(state, promptDeps({ activity: computed(() => 'idle') })); + + await ws.steerPrompt(''); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + content: [{ type: 'text', text: 'first\n\nsecond' }], + }), + ); + expect(apiMock.steerPrompts).not.toHaveBeenCalled(); + expect(state.queuedBySession.sess_1).toEqual([]); + }); + it('does NOT restore merged queue entries when a steer failure is network-ambiguous', async () => { const state = createState(); state.inFlightBySession = { sess_1: true }; @@ -1762,6 +1888,671 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { expect(state.queuedBySession.sess_1).toEqual([{ text: 'queued', attachments: undefined }]); }); + it('steers only the selected queued entry and leaves the rest of the queue', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'first', attachments: undefined }, + { text: 'second', attachments: [{ fileId: 'f_2', kind: 'image' }] }, + { text: 'third', attachments: undefined }, + ], + }; + apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_new', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + await ws.steerQueuedPrompt(1); + + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + content: [ + { type: 'text', text: 'second' }, + { type: 'image', source: { kind: 'file', fileId: 'f_2' } }, + ], + }), + ); + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_new']); + expect(state.queuedBySession.sess_1).toEqual([ + { text: 'first', attachments: undefined }, + { text: 'third', attachments: undefined }, + ]); + }); + + it('restores a single-steered entry at its original position on a definitive rejection', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'first', attachments: undefined }, + { text: 'second', attachments: undefined }, + { text: 'third', attachments: undefined }, + ], + }; + apiMock.submitPrompt.mockRejectedValue( + new DaemonApiError({ code: 50000, msg: 'boom', requestId: 'r' }), + ); + const ws = useWorkspaceState(state, promptDeps()); + + await ws.steerQueuedPrompt(1); + + expect(state.queuedBySession.sess_1).toEqual([ + { text: 'first', attachments: undefined }, + { text: 'second', attachments: undefined }, + { text: 'third', attachments: undefined }, + ]); + }); + + it('keeps the latest user order when a pending row steer is rejected', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + { text: 'C', attachments: undefined }, + ], + }; + let rejectSubmit!: (err: Error) => void; + apiMock.submitPrompt.mockImplementation( + () => + new Promise<{ promptId: string }>((_resolve, reject) => { + rejectSubmit = reject; + }), + ); + const ws = useWorkspaceState(state, promptDeps()); + + const steer = ws.steerQueuedPrompt(1); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledOnce()); + expect(state.queuedBySession.sess_1).toEqual([ + { text: 'A', attachments: undefined }, + { text: 'C', attachments: undefined }, + ]); + + // The user moves C to the front while B's request is pending. + ws.reorderQueue(1, 0); + rejectSubmit(new DaemonApiError({ code: 50000, msg: 'boom', requestId: 'r' })); + await steer; + + // C remains first; the rejected B returns to its prior numeric slot. + expect(state.queuedBySession.sess_1).toEqual([ + { text: 'C', attachments: undefined }, + { text: 'B', attachments: undefined }, + { text: 'A', attachments: undefined }, + ]); + }); + + // A turn that ends while a single-steered entry's submit is still awaiting + // the daemon must not drain the remaining queue head concurrently — the + // drained prompt could overtake the parked steer server-side and the steered + // message would land in the wrong turn. The drain waits for the steer to + // settle, then submits the head behind it. + it('defers the queue drain until an in-flight steer submission settles', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'first', attachments: undefined }, + { text: 'second', attachments: undefined }, + ], + }; + let resolveSubmit!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => new Promise((resolve) => { resolveSubmit = resolve; }), + ) + .mockResolvedValue({ promptId: 'prompt_next', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + const steerPromise = ws.steerQueuedPrompt(1); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + + // The turn ends mid-submit: the drain is deferred, no second submission. + ws.finishPromptLocal('sess_1', { turnWasActive: true }); + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + + resolveSubmit({ promptId: 'prompt_new', status: 'queued' }); + await steerPromise; + + // Once the steer settles, the deferred drain submits the remaining head. + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(2)); + expect(apiMock.submitPrompt).toHaveBeenLastCalledWith( + 'sess_1', + expect.objectContaining({ content: [{ type: 'text', text: 'first' }] }), + ); + }); + + // The exact B/C scenario from review: row-steer B out of [A, B, C], the + // turn ends mid-submit, and the drain must wait for the steer to settle + // before submitting head A — leaving C queued and FIFO intact. + it('steers B and drains head A only after the steer settles, keeping C queued', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + { text: 'C', attachments: undefined }, + ], + }; + let resolveSteer!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => { + return new Promise((resolve) => { resolveSteer = resolve; }); + }, + ) + .mockResolvedValue({ promptId: 'prompt_next', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + const steer = ws.steerQueuedPrompt(1); // row-steer B + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + + // The turn ends while B's submit is in flight: the drain is deferred. + ws.finishPromptLocal('sess_1', { turnWasActive: true }); + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + + resolveSteer({ promptId: 'prompt_b', status: 'queued' }); + await steer; + + // B was steered; only then does the drain submit head A. C stays queued. + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_b']); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(2)); + expect(apiMock.submitPrompt).toHaveBeenLastCalledWith( + 'sess_1', + expect.objectContaining({ content: [{ type: 'text', text: 'A' }] }), + ); + expect(state.queuedBySession.sess_1).toEqual([{ text: 'C', attachments: undefined }]); + }); + + // The full race the op queue exists to prevent: TWO row steers queued + // before the turn ends, plus the deferred drain. Submissions must come out + // in click order — B, then C (normal-sent: the turn has ended by the time + // its op runs), then the drained head A — never concurrently. + it('serializes two row steers and the turn-end drain in click order', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + { text: 'C', attachments: undefined }, + ], + }; + let resolveSteer!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => { + return new Promise((resolve) => { resolveSteer = resolve; }); + }, + ) + .mockResolvedValue({ promptId: 'prompt_next', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + const steerB = ws.steerQueuedPrompt(1); // row-steer B → queue [A, C] + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + const steerC = ws.steerQueuedPrompt(1); // row-steer C (now at index 1) + + // The turn ends while B's submit is in flight: the drain is deferred + // behind both steers. The macrotask flush keeps this assertion honest — + // an undeferred drain would have submitted by then. + ws.finishPromptLocal('sess_1', { turnWasActive: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + + resolveSteer({ promptId: 'prompt_b', status: 'queued' }); + await steerB; + await steerC; + + // B was steered; C's op ran after the turn ended so it normal-sent; the + // deferred drain submitted head A last. Exactly one op at a time. + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(3)); + expect(apiMock.steerPrompts).toHaveBeenCalledTimes(1); + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_b']); + const submittedTexts = apiMock.submitPrompt.mock.calls.map( + (call) => (call[1] as { content: { type: string; text?: string }[] }).content[0]?.text, + ); + expect(submittedTexts).toEqual(['B', 'C', 'A']); + expect(state.queuedBySession.sess_1).toEqual([]); + }); + + // The turn ends while a steer submit is still in flight and the user then + // sends a fresh message: it must enqueue and let the serialized drain + // submit it BEHIND the steer, never race a second concurrent POST that + // could start its turn first and pull the older steered text into it. + it('enqueues a send behind an in-flight steer submit instead of racing it', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + let resolveSteer!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => { + return new Promise((resolve) => { resolveSteer = resolve; }); + }, + ) + .mockResolvedValue({ promptId: 'prompt_next', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps({ activity: computed(() => 'idle') })); + + const steer = ws.steerPrompt('S'); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + + ws.finishPromptLocal('sess_1', { turnWasActive: true }); + const send = ws.sendPrompt('M'); + await send; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + expect(state.queuedBySession.sess_1?.map((entry) => entry.text)).toEqual(['M']); + + resolveSteer({ promptId: 'prompt_s', status: 'queued' }); + await steer; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(2); + expect(apiMock.submitPrompt).toHaveBeenLastCalledWith( + 'sess_1', + expect.objectContaining({ content: [{ type: 'text', text: 'M' }] }), + ); + }); + + // The drain deferral is the only guard when the pending turn-start bypasses + // the op queue (a direct idle send): without it the drain would submit + // concurrently with that in-flight POST. + it('defers the drain behind a direct send submit that bypasses the op queue', async () => { + const state = createState(); + let resolveSend!: (value: { promptId: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => { + return new Promise((resolve) => { resolveSend = resolve; }); + }, + ) + .mockResolvedValue({ promptId: 'prompt_next', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps({ activity: computed(() => 'idle') })); + + const send = ws.sendPrompt('X'); // direct submit, still in flight + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + + // A queued entry appears and the turn ends while X's POST is in flight. + state.queuedBySession = { sess_1: [{ text: 'Y', attachments: undefined }] }; + ws.finishPromptLocal('sess_1', { turnWasActive: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + + resolveSend({ promptId: 'prompt_x' }); + await send; + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(2)); + expect(apiMock.submitPrompt).toHaveBeenLastCalledWith( + 'sess_1', + expect.objectContaining({ content: [{ type: 'text', text: 'Y' }] }), + ); + }); + + // A deferred drain and a stale-snapshot retry can land in the same pending + // window — both callbacks must survive registration, not overwrite each + // other (single-slot storage used to drop the earlier one). + it('runs every afterLocalTurnStartsSettle callback, not just the last one', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [{ text: 'entry', attachments: undefined }], + }; + let resolveSubmit!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt.mockImplementation( + () => new Promise((resolve) => { resolveSubmit = resolve; }), + ); + const ws = useWorkspaceState(state, promptDeps()); + + const steerPromise = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + + const calls: string[] = []; + ws.afterLocalTurnStartsSettle('sess_1', () => calls.push('first')); + ws.afterLocalTurnStartsSettle('sess_1', () => calls.push('second')); + + resolveSubmit({ promptId: 'prompt_new', status: 'queued' }); + await steerPromise; + + expect(calls).toEqual(['first', 'second']); + }); + + // Two rapid per-row steers whose submits are both definitively rejected + // must restore their entries without corrupting FIFO order: removals and + // restores interleave in click order because per-entry steers are + // serialized per session, and each entry returns to its removal slot in + // the latest visible order. + it('preserves queue order when two concurrent per-entry steers are both rejected', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + { text: 'C', attachments: undefined }, + ], + }; + apiMock.submitPrompt.mockRejectedValue( + new DaemonApiError({ code: 50000, msg: 'boom', requestId: 'r' }), + ); + const ws = useWorkspaceState(state, promptDeps()); + + await Promise.all([ws.steerQueuedPrompt(0), ws.steerQueuedPrompt(1)]); + + expect(state.queuedBySession.sess_1).toEqual([ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + { text: 'C', attachments: undefined }, + ]); + }); + + // Steer liveness must be judged from the captured session's own state: a + // serialized per-entry steer can fire after the user switched to an idle + // session, and the active view's `activity` would wrongly report idle while + // the captured session's turn (observed from another client) still runs. + it('judges steer liveness from the captured session, not the active view', async () => { + const state = createState(); + // Turn observed from another client: turnActive, not locally in flight. + state.turnActiveBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [{ text: 'entry', attachments: undefined }], + }; + apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_new', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps({ activity: computed(() => 'idle') })); + + await ws.steerQueuedPrompt(0); + + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_new']); + expect(state.queuedBySession.sess_1).toEqual([]); + }); + + // Row steers, whole-queue steers, and drains all share one per-session op + // queue: a whole-queue steer clicked while a per-entry steer is in flight + // must not submit concurrently — it merges and submits only after the + // first op settles. + it('serializes a whole-queue steer behind an in-flight per-entry steer', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + ], + }; + let resolveFirst!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => { + return new Promise((resolve) => { resolveFirst = resolve; }); + }, + ) + .mockResolvedValueOnce({ promptId: 'prompt_merged', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + const row = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + const all = ws.steerPrompt(''); + // The whole-queue steer is queued behind the in-flight one: nothing else + // submits (and the queue is not cleared) until the first op settles. A + // macrotask flush makes this assertion meaningful — a concurrent op would + // have reached the API by then. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1); + expect(state.queuedBySession.sess_1).toEqual([{ text: 'B', attachments: undefined }]); + + resolveFirst({ promptId: 'prompt_a', status: 'queued' }); + await row; + await all; + + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(2); + expect(apiMock.submitPrompt).toHaveBeenLastCalledWith( + 'sess_1', + expect.objectContaining({ content: [{ type: 'text', text: 'B' }] }), + ); + expect(apiMock.steerPrompts).toHaveBeenNthCalledWith(1, 'sess_1', ['prompt_a']); + expect(apiMock.steerPrompts).toHaveBeenNthCalledWith(2, 'sess_1', ['prompt_merged']); + }); + + // Same rule as the queue drain (see 'does not resurrect the queue …' + // below): a definitive rejection after the session was forgotten must not + // recreate its discarded queue. + it('does not resurrect the queue when a steer is rejected after the session was forgotten', async () => { + let rejectSubmit!: (err: Error) => void; + apiMock.submitPrompt.mockImplementation( + () => { + return new Promise<{ promptId: string }>((_resolve, reject) => { + rejectSubmit = reject; + }); + }, + ); + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [{ text: 'doomed', attachments: undefined }], + }; + const ws = useWorkspaceState(state, promptDeps()); + + const steer = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalled()); + expect(state.queuedBySession.sess_1 ?? []).toEqual([]); + + // Facade forget path (e.g. archive) while the steer submit is pending. + state.sessions = []; + delete state.queuedBySession.sess_1; + rejectSubmit(new DaemonApiError({ code: 50000, msg: 'gone', requestId: 'r' })); + await steer; + + expect(state.queuedBySession.sess_1).toBeUndefined(); + }); + + it('invalidates pending steers when archive and restore reuse the session id', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + ], + }; + let rejectSubmit!: (err: Error) => void; + apiMock.submitPrompt.mockImplementation( + () => + new Promise<{ promptId: string }>((_resolve, reject) => { + rejectSubmit = reject; + }), + ); + const deps = promptDeps({ + sideChat: { + clearSideChatForSession: vi.fn(), + } as unknown as UseWorkspaceStateDeps['sideChat'], + forgetSession: vi.fn((id: string) => { + forgetLocalTurnState(id); + state.sessions = state.sessions.filter((session) => session.id !== id); + delete state.queuedBySession[id]; + delete state.inFlightBySession[id]; + delete state.turnActiveBySession[id]; + }), + upsertSessionFront: vi.fn((session: AppSession) => { + state.sessions = [ + session, + ...state.sessions.filter((existing) => existing.id !== session.id), + ]; + }), + }); + const ws = useWorkspaceState(state, deps); + + const rowSteer = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledOnce()); + const queuedWholeSteer = ws.steerPrompt('live'); + + // Archive invalidates both the in-flight row steer and the whole-queue + // operation waiting behind it. Restoring the same id must not revive either. + state.activeSessionId = undefined; + await ws.archiveSession('sess_1'); + await ws.restoreSession('sess_1'); + rejectSubmit(new DaemonApiError({ code: 50000, msg: 'gone', requestId: 'r' })); + await Promise.all([rowSteer, queuedWholeSteer]); + + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + expect(apiMock.steerPrompts).not.toHaveBeenCalled(); + expect(state.queuedBySession.sess_1).toBeUndefined(); + expect(deps.pushOperationFailure).not.toHaveBeenCalledWith( + 'steer', + expect.anything(), + expect.anything(), + ); + }); + + it('blocks chained steers as soon as archive starts, before the archive response', async () => { + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + ], + }; + let resolveSubmit!: (value: { promptId: string; status: string }) => void; + let resolveArchive!: () => void; + apiMock.submitPrompt.mockImplementation( + () => new Promise((resolve) => { resolveSubmit = resolve; }), + ); + apiMock.archiveSession.mockImplementation( + () => new Promise((resolve) => { resolveArchive = resolve; }), + ); + const deps = promptDeps({ + sideChat: { + clearSideChatForSession: vi.fn(), + } as unknown as UseWorkspaceStateDeps['sideChat'], + forgetSession: vi.fn((id: string) => { + forgetLocalTurnState(id); + state.sessions = state.sessions.filter((session) => session.id !== id); + delete state.queuedBySession[id]; + delete state.inFlightBySession[id]; + delete state.turnActiveBySession[id]; + }), + }); + const ws = useWorkspaceState(state, deps); + + const rowSteer = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledOnce()); + const queuedWholeSteer = ws.steerPrompt('live'); + const archive = ws.archiveSession('sess_1'); + + resolveSubmit({ promptId: 'prompt_a', status: 'queued' }); + await Promise.resolve(); + + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + expect(apiMock.steerPrompts).not.toHaveBeenCalled(); + + resolveArchive(); + await Promise.all([archive, rowSteer, queuedWholeSteer]); + + expect(apiMock.submitPrompt).toHaveBeenCalledOnce(); + expect(apiMock.steerPrompts).not.toHaveBeenCalled(); + expect(state.queuedBySession.sess_1).toBeUndefined(); + }); + + it('releases chained steers when archive fails', async () => { + const archiveError = new Error('archive failed'); + const state = createState(); + state.inFlightBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + ], + }; + let resolveFirstSubmit!: (value: { promptId: string; status: string }) => void; + let rejectArchive!: (error: Error) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => new Promise((resolve) => { resolveFirstSubmit = resolve; }), + ) + .mockResolvedValueOnce({ promptId: 'prompt_b', status: 'queued' }); + apiMock.archiveSession.mockImplementation( + () => new Promise((_resolve, reject) => { rejectArchive = reject; }), + ); + const deps = promptDeps(); + const ws = useWorkspaceState(state, deps); + + const rowSteer = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledOnce()); + const queuedWholeSteer = ws.steerPrompt('live'); + const archive = ws.archiveSession('sess_1'); + + resolveFirstSubmit({ promptId: 'prompt_a', status: 'queued' }); + await Promise.resolve(); + expect(apiMock.steerPrompts).not.toHaveBeenCalled(); + + rejectArchive(archiveError); + await Promise.all([archive, rowSteer, queuedWholeSteer]); + + expect(apiMock.steerPrompts).toHaveBeenNthCalledWith(1, 'sess_1', ['prompt_a']); + expect(apiMock.steerPrompts).toHaveBeenNthCalledWith(2, 'sess_1', ['prompt_b']); + expect(apiMock.submitPrompt).toHaveBeenCalledTimes(2); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('archiveSession', archiveError, { + sessionId: 'sess_1', + }); + }); + + // The full session-switch scenario: a serialized per-entry steer fires + // after the user switched to an idle session — liveness still comes from + // the captured session, so the entry is steered, not normal-sent. + it('steers a serialized per-entry steer after a mid-flight session switch', async () => { + const state = createState(); + state.activeSessionId = 'sess_1'; + state.turnActiveBySession = { sess_1: true }; + state.queuedBySession = { + sess_1: [ + { text: 'A', attachments: undefined }, + { text: 'B', attachments: undefined }, + ], + }; + let resolveFirst!: (value: { promptId: string; status: string }) => void; + apiMock.submitPrompt + .mockImplementationOnce( + () => { + return new Promise((resolve) => { resolveFirst = resolve; }); + }, + ) + .mockResolvedValueOnce({ promptId: 'prompt_b', status: 'queued' }); + // Activity follows the active view, like the real computed. + const ws = useWorkspaceState( + state, + promptDeps({ + activity: computed(() => { + return state.activeSessionId === 'sess_1' ? 'running' : 'idle'; + }), + }), + ); + + const first = ws.steerQueuedPrompt(0); + await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1)); + const second = ws.steerQueuedPrompt(0); + state.activeSessionId = 'sess_2'; // the user switches before the first settles + + resolveFirst({ promptId: 'prompt_a', status: 'queued' }); + await first; + await second; + + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_b']); + }); + + it('steers a queued prompt when only session metadata marks the main turn active', async () => { + const state = createState(); + state.sessions = [{ ...createSession(), mainTurnActive: true }]; + state.queuedBySession = { + sess_1: [{ text: 'queued', attachments: undefined }], + }; + apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_queued', status: 'queued' }); + const ws = useWorkspaceState(state, promptDeps()); + + await ws.steerQueuedPrompt(0); + + expect(apiMock.steerPrompts).toHaveBeenCalledWith('sess_1', ['prompt_queued']); + }); + // A background session's drained prompt must not inherit the thinking level // of whichever session is active when the drain happens — the level is // resolved from the prompt's OWN model, never the active-view global. @@ -2007,11 +2798,11 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { const ws = useWorkspaceState(state, promptDeps()); ws.finishPromptLocal('sess_1', { turnWasActive: true }); - expect(state.queuedBySession.sess_1 ?? []).toEqual([]); // Facade forget path (e.g. archive) while the submit is pending. The // daemon definitively rejects afterwards — even then, no resurrection. await vi.waitFor(() => expect(apiMock.submitPrompt).toHaveBeenCalled()); + expect(state.queuedBySession.sess_1 ?? []).toEqual([]); state.sessions = []; delete state.queuedBySession.sess_1; rejectSubmit(new DaemonApiError({ code: 50000, msg: 'network down', requestId: 'r' })); diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4f6bded88c..6de0b48061 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1010,7 +1010,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { @@ -146,6 +149,7 @@ export class AgentPromptService implements IAgentPromptService { }, () => {}); const turn = (await this.loop.enqueue(request).assigned).turn; if (turn === undefined) throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into'); + this.telemetry.track2('input_steer', { parts: message.content.length }); for (const item of selected) { item.state = 'steered'; item.launchedDeferred.resolve(turn); } this.steered.set(this.active.id, [...(this.steered.get(this.active.id) ?? []), ...selected]); this.eventBus.publish({ type: 'prompt.steered', activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: rerouted.content as ContentPart[], steeredAt: new Date().toISOString() }); diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index bb7ffef1d4..361a0f39da 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -108,7 +108,6 @@ export class AgentRPCService implements IAgentRPCService { } async steer(payload: SteerPayload): Promise { - this.telemetry.track2('input_steer', { parts: payload.input.length }); const queued = await this.promptService.enqueue({ message: { role: 'user', content: [...payload.input], diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 013b919830..f39474a3d9 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -22,6 +22,7 @@ import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminde import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2 } from '#/errors'; import { createHooks } from '#/hooks'; import { IWireService } from '#/wire/wire'; @@ -29,6 +30,7 @@ import { IWireService } from '#/wire/wire'; import { stubContextMemory } from '../contextMemory/stubs'; import { stubLoopWithHooks, stubToolExecutor, stubWire } from '../loop/stubs'; import { registerStateServices } from '../../state/stubs'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; function message(text: string): ContextMessage { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' } }; @@ -46,6 +48,7 @@ function harness() { hooks: createHooks(['onWillCompact']), onDidFinishCompaction: Event.None, } as unknown as IAgentFullCompactionService; + const telemetryRecords: TelemetryRecord[] = []; const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { registerStateServices(reg); @@ -54,12 +57,13 @@ function harness() { reg.defineInstance(IWireService, stubWire()); reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); reg.defineInstance(IAgentFullCompactionService, fullCompaction); + reg.defineInstance(ITelemetryService, recordingTelemetry(telemetryRecords)); reg.define(IEventBus, EventBusService); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentPromptService, AgentPromptService); } }); - return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction }; + return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, telemetryRecords }; } describe('AgentPromptService', () => { @@ -98,6 +102,18 @@ describe('AgentPromptService', () => { loop.drainNextBatch(context); }); + it('emits input_steer telemetry when pending prompts are steered', async () => { + const { prompt, telemetryRecords } = harness(); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + const one = await prompt.enqueue({ message: message('one') }); + const two = await prompt.enqueue({ message: message('two') }); + await prompt.steer([two.id, one.id]); + expect(telemetryRecords.filter((record) => record.event === 'input_steer')).toEqual([ + { event: 'input_steer', properties: { parts: 2 } }, + ]); + }); + it('aborts pending prompts and settles completion', async () => { const { prompt } = harness(); await prompt.enqueue({ message: message('active') });