fix(repl): queue prompt guidance for next turn (#333)

Keep normal prompt submissions during generation queued instead of interrupting the current turn. Add a visible next-turn banner in the prompt area so users can tell their follow-up guidance was accepted, and cover the new behavior with focused tests.

Fixes #328

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
KRATOS
2026-04-04 17:57:59 +05:30
committed by GitHub
parent fbf3385395
commit cdc92d16e4
4 changed files with 147 additions and 8 deletions

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { renderToString } from '../../utils/staticRender.js'
describe('PromptInputQueuedCommands', () => {
beforeEach(() => {
mock.module('../../hooks/useCommandQueue.js', () => ({
useCommandQueue: () => [
{
value: 'Use another library',
mode: 'prompt',
},
],
}))
mock.module('src/state/AppState.js', () => ({
useAppState: (
selector: (state: { viewingAgentTaskId?: string; isBriefOnly: boolean }) => unknown,
) => selector({ viewingAgentTaskId: undefined, isBriefOnly: false }),
}))
})
afterEach(() => {
mock.restore()
})
it('shows a next-turn guidance banner for queued prompt messages', async () => {
const { PromptInputQueuedCommands } = await import('./PromptInputQueuedCommands.js')
const output = await renderToString(<PromptInputQueuedCommands />, 100)
expect(output).toContain('1 message queued for next turn')
expect(output).toContain('Use another library')
})
})

View File

@@ -1,13 +1,14 @@
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle';
import * as React from 'react'; import * as React from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Box } from 'src/ink.js'; import { Box, Text } from 'src/ink.js';
import { useAppState } from 'src/state/AppState.js'; import { useAppState } from 'src/state/AppState.js';
import type { AppState } from 'src/state/AppState.js';
import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js'; import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js';
import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js'; import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js';
import { useCommandQueue } from '../../hooks/useCommandQueue.js'; import { useCommandQueue } from '../../hooks/useCommandQueue.js';
import type { QueuedCommand } from '../../types/textInputTypes.js'; import type { QueuedCommand } from '../../types/textInputTypes.js';
import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js'; import { isQueuedCommandEditable, isQueuedCommandVisible } from '../../utils/messageQueueManager.js';
import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js';
import { jsonParse } from '../../utils/slowOperations.js'; import { jsonParse } from '../../utils/slowOperations.js';
import { Message } from '../Message.js'; import { Message } from '../Message.js';
@@ -70,17 +71,25 @@ function processQueuedCommands(queuedCommands: QueuedCommand[]): QueuedCommand[]
} }
function PromptInputQueuedCommandsImpl(): React.ReactNode { function PromptInputQueuedCommandsImpl(): React.ReactNode {
const queuedCommands = useCommandQueue(); const queuedCommands = useCommandQueue();
const viewingAgent = useAppState(s => !!s.viewingAgentTaskId); const viewingAgent = useAppState((s: AppState) => !!s.viewingAgentTaskId);
// Brief layout: dim queue items + skip the paddingX (brief messages // Brief layout: dim queue items + skip the paddingX (brief messages
// already indent themselves). Gate mirrors the brief-spinner/message // already indent themselves). Gate mirrors the brief-spinner/message
// check elsewhere — no teammate-view override needed since this // check elsewhere — no teammate-view override needed since this
// component early-returns when viewing a teammate. // component early-returns when viewing a teammate.
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_0 => s_0.isBriefOnly) : false; useAppState((s_0: AppState) => s_0.isBriefOnly) : false;
// createUserMessage mints a fresh UUID per call; without memoization, streaming // createUserMessage mints a fresh UUID per call; without memoization, streaming
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker. // re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
const queuedPromptCount = useMemo(
() =>
queuedCommands.filter(
cmd => isQueuedCommandEditable(cmd) && cmd.mode === 'prompt',
).length,
[queuedCommands],
);
const messages = useMemo(() => { const messages = useMemo(() => {
if (queuedCommands.length === 0) return null; if (queuedCommands.length === 0) return null;
// task-notification is shown via useInboxNotification; most isMeta commands // task-notification is shown via useInboxNotification; most isMeta commands
@@ -108,6 +117,11 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
return null; return null;
} }
return <Box marginTop={1} flexDirection="column"> return <Box marginTop={1} flexDirection="column">
{queuedPromptCount > 0 && <Box marginLeft={2} marginBottom={1}>
<Text dimColor>
{queuedPromptCount === 1 ? '1 message queued for next turn' : `${queuedPromptCount} messages queued for next turn`}
</Text>
</Box>}
{messages.map((message, i) => <QueuedMessageProvider key={i} isFirst={i === 0} useBriefLayout={useBriefLayout}> {messages.map((message, i) => <QueuedMessageProvider key={i} isFirst={i === 0} useBriefLayout={useBriefLayout}>
<Message message={message} lookups={EMPTY_LOOKUPS} addMargin={false} tools={[]} commands={[]} verbose={false} inProgressToolUseIDs={EMPTY_SET} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} /> <Message message={message} lookups={EMPTY_LOOKUPS} addMargin={false} tools={[]} commands={[]} verbose={false} inProgressToolUseIDs={EMPTY_SET} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} />
</QueuedMessageProvider>)} </QueuedMessageProvider>)}

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { getCommandQueue, resetCommandQueue } from './messageQueueManager.js'
describe('handlePromptSubmit', () => {
beforeEach(() => {
resetCommandQueue()
mock.module('src/services/analytics/index.js', () => ({
logEvent: () => {},
}))
})
afterEach(() => {
resetCommandQueue()
mock.restore()
})
it('queues prompt submissions during generation without interrupting the current turn', async () => {
const { handlePromptSubmit } = await import('./handlePromptSubmit.js')
const abortCalls: unknown[] = []
const inputChanges: string[] = []
let cursorOffset = 123
let bufferCleared = false
let pastedContentsCleared = false
let historyReset = false
await handlePromptSubmit({
input: ' use another library ',
mode: 'prompt',
pastedContents: {},
helpers: {
setCursorOffset: offset => {
cursorOffset = offset
},
clearBuffer: () => {
bufferCleared = true
},
resetHistory: () => {
historyReset = true
},
},
onInputChange: value => {
inputChanges.push(value)
},
setPastedContents: updater => {
const nextValue =
typeof updater === 'function'
? updater({ 1: { id: 1, type: 'text', content: 'x' } })
: updater
pastedContentsCleared = Object.keys(nextValue).length === 0
},
abortController: {
abort: (reason: unknown) => {
abortCalls.push(reason)
},
} as never,
hasInterruptibleToolInProgress: true,
queryGuard: {
isActive: true,
} as never,
isExternalLoading: false,
commands: [],
messages: [],
mainLoopModel: 'sonnet',
ideSelection: undefined,
querySource: 'repl' as never,
setToolJSX: () => {},
getToolUseContext: () => ({}) as never,
setUserInputOnProcessing: () => {},
setAbortController: () => {},
onQuery: async () => {},
setAppState: () => ({}) as never,
})
expect(abortCalls).toEqual([])
expect(inputChanges).toEqual([''])
expect(cursorOffset).toBe(0)
expect(bufferCleared).toBe(true)
expect(pastedContentsCleared).toBe(true)
expect(historyReset).toBe(true)
expect(getCommandQueue()).toMatchObject([
{
value: 'use another library',
preExpansionValue: 'use another library',
mode: 'prompt',
},
])
})
})

View File

@@ -316,9 +316,10 @@ export async function handlePromptSubmit(
return return
} }
// Interrupt the current turn when all executing tools have // Prompt submissions during generation should guide the next turn without
// interruptBehavior 'cancel' (e.g. SleepTool). // interrupting the current one. Keep the explicit interrupt path only for
if (params.hasInterruptibleToolInProgress) { // non-prompt inputs that opt into that behavior.
if (mode !== 'prompt' && params.hasInterruptibleToolInProgress) {
logForDebugging( logForDebugging(
`[interrupt] Aborting current turn: streamMode=${params.streamMode}`, `[interrupt] Aborting current turn: streamMode=${params.streamMode}`,
) )