Skip to content
Merged
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
28 changes: 26 additions & 2 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ const {
messages: [] as unknown[],
queuedPromptHoldHistory: [] as boolean[],
queuedPromptStreamingState: 'idle',
queuedPromptSessionHasActivePrompt: false,
chatEditorRenderCount: 0,
latestChatEditorProps: null as ChatEditorTestProps | null,
onChatEditorLayout: null as ((props: ChatEditorTestProps) => void) | null,
Expand Down Expand Up @@ -605,11 +606,14 @@ vi.mock('./hooks/useQueuedPrompts', () => ({
useQueuedPrompts: (args: {
holdQueuedPromptsLocally?: boolean;
streamingState: string;
sessionHasActivePrompt?: boolean;
}) => {
testState.queuedPromptHoldHistory.push(
args.holdQueuedPromptsLocally === true,
);
testState.queuedPromptStreamingState = args.streamingState;
testState.queuedPromptSessionHasActivePrompt =
args.sessionHasActivePrompt === true;
return {
queuedPrompts: [],
queuedTexts,
Expand Down Expand Up @@ -4795,6 +4799,7 @@ beforeEach(() => {
testState.messages = [];
testState.queuedPromptHoldHistory = [];
testState.queuedPromptStreamingState = 'idle';
testState.queuedPromptSessionHasActivePrompt = false;
testState.chatEditorRenderCount = 0;
testState.latestChatEditorProps = null;
testState.onChatEditorLayout = null;
Expand Down Expand Up @@ -10475,7 +10480,7 @@ describe('App session callbacks', () => {
mockConnection.missingSession = true;

const onSessionIdChange = vi.fn();
const { container } = renderApp({
const { container, rerender } = renderApp({
onSessionIdChange,
});
await flush();
Expand All @@ -10497,6 +10502,23 @@ describe('App session callbacks', () => {
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
expect(onSessionIdChange).toHaveBeenCalledWith(undefined);
expect(onSessionIdChange).toHaveBeenCalledTimes(1);

mockConnection.status = 'connected';
mockConnection.sessionId = undefined;
mockConnection.error = undefined;
mockConnection.errorStatus = undefined;
mockConnection.missingSession = false;
testState.sessionHasActivePrompt = false;
rerender();
await flush();

await act(async () => {
testState.latestChatEditorProps?.onSubmit('first message');
await flush();
});

expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
},
);

Expand Down Expand Up @@ -11130,7 +11152,8 @@ describe('App session callbacks', () => {
expect(rawEnqueuePrompt.mock.calls[0]?.[0]).toBe(
'hello before first token',
);
expect(testState.queuedPromptStreamingState).toBe('responding');
expect(testState.queuedPromptStreamingState).toBe('idle');
expect(testState.queuedPromptSessionHasActivePrompt).toBe(true);

mockSessionActions.sendPrompt.mockClear();
rawEnqueuePrompt.mockClear();
Expand All @@ -11144,6 +11167,7 @@ describe('App session callbacks', () => {
});

expect(testState.queuedPromptStreamingState).toBe('idle');
expect(testState.queuedPromptSessionHasActivePrompt).toBe(false);
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
});
Expand Down
10 changes: 4 additions & 6 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4227,10 +4227,6 @@ export function App({
const [isStartingNewSessionSuggestion, setIsStartingNewSessionSuggestion] =
useState(false);
const streamingState = useStreamingState();
const queuedPromptStreamingState =
streamingState === 'idle' && sessionHasActivePrompt
? 'responding'
: streamingState;
const failedPromptRetryIsCurrent = Boolean(
failedPromptRetry &&
retryOwnerMatchesCurrent(
Expand Down Expand Up @@ -6315,7 +6311,8 @@ export function App({
canQueryMidTurn,
canInjectMidTurnMedia,
workspaceFileActions: artifactWorkspaceActions,
streamingState: queuedPromptStreamingState,
streamingState,
sessionHasActivePrompt,
sessionActions,
store,
editorRef,
Expand Down Expand Up @@ -12768,7 +12765,8 @@ export function App({
t={t}
canMutateMidTurn={canMutateMidTurn}
canInsertMidTurn={
queuedPromptStreamingState !== 'idle'
streamingState !== 'idle' ||
sessionHasActivePrompt
}
onDelete={removeQueuedPrompt}
onInsert={insertQueuedPrompt}
Expand Down
13 changes: 11 additions & 2 deletions packages/web-shell/client/components/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ let streamingStateValue: string;
let pendingPermission: any;
let sessionHasActivePromptValue: boolean;
let queuedPromptStreamingState: string | undefined;
let queuedPromptSessionHasActivePrompt: boolean | undefined;
let latestOnSubmit:
| ((
text: string,
Expand Down Expand Up @@ -142,8 +143,12 @@ vi.mock('../session-catalog/session-catalog-hooks', () => ({
}));

vi.mock('../hooks/useQueuedPrompts', () => ({
useQueuedPrompts: (args: { streamingState: string }) => {
useQueuedPrompts: (args: {
streamingState: string;
sessionHasActivePrompt?: boolean;
}) => {
queuedPromptStreamingState = args.streamingState;
queuedPromptSessionHasActivePrompt = args.sessionHasActivePrompt;
return {
queuedPrompts: queuedPromptsMock,
queuedTexts: queuedTextsMock,
Expand Down Expand Up @@ -409,6 +414,7 @@ beforeEach(() => {
renderRealChatEditor = false;
sessionHasActivePromptValue = false;
queuedPromptStreamingState = undefined;
queuedPromptSessionHasActivePrompt = undefined;
latestComposerCoreOptions.current = null;
latestFollowupAccept = undefined;
latestMonitorDetailsOnOpen = undefined;
Expand Down Expand Up @@ -1657,6 +1663,7 @@ describe('ChatPane', () => {
expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).toHaveBeenCalled();
expect(queuedPromptStreamingState).toBe('responding');
expect(queuedPromptSessionHasActivePrompt).toBe(false);
});

it('inserts a prompt before the first stream event reaches the pane', () => {
Expand All @@ -1671,7 +1678,8 @@ describe('ChatPane', () => {

expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).toHaveBeenCalled();
expect(queuedPromptStreamingState).toBe('responding');
expect(queuedPromptStreamingState).toBe('idle');
expect(queuedPromptSessionHasActivePrompt).toBe(true);

sendPrompt.mockClear();
enqueuePrompt.mockClear();
Expand All @@ -1686,6 +1694,7 @@ describe('ChatPane', () => {
);

expect(queuedPromptStreamingState).toBe('idle');
expect(queuedPromptSessionHasActivePrompt).toBe(false);
expect(sendPrompt).toHaveBeenCalledTimes(1);
expect(enqueuePrompt).not.toHaveBeenCalled();
});
Expand Down
11 changes: 5 additions & 6 deletions packages/web-shell/client/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,6 @@ export function ChatPane({
const transcriptHistory = useTranscriptHistory();
const store = useTranscriptStore();
const streamingState = useStreamingState();
const queuedPromptStreamingState =
streamingState === 'idle' && sessionHasActivePrompt
? 'responding'
: streamingState;
const [goalControlBusy, setGoalControlBusy] = useState(false);
const goalControlOpSeqRef = useRef(0);
const goalControlOwnerRef = useRef<
Expand Down Expand Up @@ -608,7 +604,8 @@ export function ChatPane({
canQueryMidTurn,
canInjectMidTurnMedia,
workspaceFileActions: attachmentWorkspaceTarget?.actions,
streamingState: queuedPromptStreamingState,
streamingState,
sessionHasActivePrompt,
sessionActions: actions,
store,
editorRef,
Expand Down Expand Up @@ -1351,7 +1348,9 @@ export function ChatPane({
prompts={queuedPrompts}
t={t}
canMutateMidTurn={canMutateMidTurn}
canInsertMidTurn={queuedPromptStreamingState !== 'idle'}
canInsertMidTurn={
streamingState !== 'idle' || sessionHasActivePrompt
}
onDelete={removeQueuedPrompt}
onInsert={insertQueuedPrompt}
onEdit={editQueuedPrompt}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ interface HarnessOptions {
canQueryMidTurn?: boolean;
canInjectMidTurnMedia?: boolean;
streamingState?: DaemonStreamingState;
sessionHasActivePrompt?: boolean;
holdQueuedPromptsLocally?: boolean;
}

Expand Down Expand Up @@ -155,6 +156,7 @@ function createHarness() {
canInjectMidTurnMedia: opts.canInjectMidTurnMedia ?? true,
workspaceFileActions: stableWorkspaceFileActions as never,
streamingState: opts.streamingState ?? 'responding',
sessionHasActivePrompt: opts.sessionHasActivePrompt ?? false,
holdQueuedPromptsLocally: opts.holdQueuedPromptsLocally ?? false,
sessionActions: sdkMock.actions as never,
store: stableStore as never,
Expand Down Expand Up @@ -576,6 +578,93 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
}
});

it('falls back when live state is active but raw streaming is idle', async () => {
let resolveAdmission:
| ((value: { accepted: boolean; messageId?: string }) => void)
| undefined;
sdkMock.actions.enqueueMidTurnMessage.mockImplementation(
(_message: string, opts?: { onAdmissionStarted?: () => void }) =>
new Promise((resolve) => {
opts?.onAdmissionStarted?.();
resolveAdmission = resolve;
}),
);
const harness = createHarness();
try {
await harness.render({
streamingState: 'idle',
sessionHasActivePrompt: true,
});
await act(async () => {
harness.result().enqueuePrompt('live state race');
});
await act(async () => {
resolveAdmission?.({ accepted: false });
});

expect(sdkMock.actions.enqueueMidTurnMessage).toHaveBeenCalledOnce();
expect(sdkMock.actions.submitPrompt).toHaveBeenCalledOnce();
expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith(
'live state race',
expect.objectContaining({ sessionId: 'session-a' }),
);
expect(harness.reportError).not.toHaveBeenCalled();
} finally {
await harness.dispose();
}
});

it('preserves file annotations when a live-state insert falls back', async () => {
const fileText = '@docs/notes.txt';
const text = `${fileText} explain this`;
const annotation = {
type: 'reference' as const,
start: 0,
end: fileText.length,
text: fileText,
reference: {
id: 'file:docs/notes.txt',
kind: 'file' as const,
value: 'docs/notes.txt',
},
};
sdkMock.actions.enqueueMidTurnMessage.mockImplementationOnce(
(_message: string, opts?: { onAdmissionStarted?: () => void }) => {
opts?.onAdmissionStarted?.();
return Promise.resolve({ accepted: false });
},
);
const harness = createHarness();
try {
await harness.render({
streamingState: 'idle',
sessionHasActivePrompt: true,
});
await act(async () => {
harness
.result()
.enqueuePrompt(text, undefined, undefined, undefined, [annotation]);
await Promise.resolve();
});

expect(sdkMock.actions.removeAttachment).toHaveBeenCalledWith(
'notes.txt',
{ sessionId: 'session-a' },
);
expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith(
text,
expect.objectContaining({
files: undefined,
inputAnnotations: [annotation],
sessionId: 'session-a',
}),
);
expect(harness.reportError).not.toHaveBeenCalled();
} finally {
await harness.dispose();
}
});

it('does not resubmit when an accepted response arrives after idle', async () => {
let resolveAdmission:
| ((value: { accepted: boolean; messageId?: string }) => void)
Expand Down
Loading
Loading