Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
229276e
fix(web-shell): allow shell commands in new tasks without a session
wenshao Jul 25, 2026
b298691
feat(web-shell): queue shell commands during a turn instead of blocking
wenshao Jul 25, 2026
da71f01
fix(web-shell): clear queued shell commands on session switch
qwen-code-ci-bot Jul 26, 2026
dd91f40
fix(web-shell): pin drain session, clear queue on cancel, deduplicate…
qwen-code-ci-bot Jul 26, 2026
1951ffb
fix(web-shell): stop active drain on cancel, warn on dropped commands…
qwen-code-ci-bot Jul 26, 2026
043beff
Merge branch 'main' into fix/webshell-shell-command-new-task
qwen-code-dev-bot Jul 26, 2026
c026523
fix(web-shell): keep draining queued shell commands as streamingState…
qwen-code-dev-bot Jul 26, 2026
162ffc5
Merge branch 'main' into fix/webshell-shell-command-new-task
wenshao Jul 26, 2026
8ee890e
fix(web-shell): defer composer clear for ! commands until session cre…
Jul 26, 2026
fe57165
fix(web-shell): scope drain cancellation to generation and harden new…
Jul 26, 2026
ba2f1e1
fix(web-shell): warn on cancel drop and guard queue wipe against lang…
Jul 26, 2026
dda0090
fix(web-shell): assert cancel call in tests and document drain orderi…
qwen-code-ci-bot Jul 26, 2026
5591d5b
fix(web-shell): guard inline ! command against cancel and session-swi…
qwen-code-ci-bot Jul 26, 2026
45e6473
Merge branch 'main' into fix/webshell-shell-command-new-task
qwen-code-dev-bot Jul 26, 2026
c51919b
fix(web-shell): exempt lazy session creation from drain-generation bu…
Jul 26, 2026
f182512
fix(web-shell): prevent drain preemption when commands are queued mid…
Jul 26, 2026
b0a7c51
fix(web-shell): reset shellSubmitInFlightRef on cancel to unblock new…
Jul 26, 2026
7695a1a
fix(web-shell): drop whole queue on drain bail to preserve FIFO (#7724)
Jul 27, 2026
88703ca
fix(web-shell): scope drain-bail queue wipe to disconnect only (#7724)
qwen-code-ci-bot Jul 27, 2026
cb775f7
fix(web-shell): use returned session ID instead of stale connectionRe…
qwen-code-ci-bot Jul 27, 2026
3a1b506
test(web-shell): cover sendShellCommand rejection and bare-bang guard…
qwen-code-ci-bot Jul 27, 2026
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
311 changes: 311 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type ChatEditorTestProps = {
commitAccepted?: () => void,
metadata?: { inputAnnotations?: DaemonInputAnnotation[] },
) => boolean | void;
onCancel?: () => void;
onInputTextChange?: (text: string) => void;
onStartNewSessionSuggestion?: () => void;
newSessionSuggestion?: { isVisible: boolean; classifiedInput: string } | null;
Expand Down Expand Up @@ -137,6 +138,7 @@ const {
clearGoal: vi.fn().mockResolvedValue(undefined),
forkSession: vi.fn().mockResolvedValue({ launched: false }),
sendShellCommand: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
getStats: vi.fn().mockResolvedValue({}),
loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }),
loadSession: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -1076,6 +1078,7 @@ beforeEach(() => {
mockSessionActions.clearGoal.mockResolvedValue(undefined);
mockSessionActions.forkSession.mockResolvedValue({ launched: false });
mockSessionActions.sendShellCommand.mockResolvedValue(undefined);
mockSessionActions.cancel.mockResolvedValue(undefined);
mockSessionActions.getStats.mockResolvedValue({});
mockSessionActions.loadSession.mockResolvedValue(undefined);
mockStore.reset.mockClear();
Expand Down Expand Up @@ -1116,6 +1119,314 @@ afterEach(() => {
vi.restoreAllMocks();
});

describe('App shell command queueing', () => {
it('lazily creates a session for ! shell commands in a new task', async () => {
mockConnection.sessionId = undefined;
renderApp();
await flush();

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!echo hi');
await vi.waitFor(() => {
expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith(
'echo hi',
);
});
});
expect(mockSessionActions.createSession).toHaveBeenCalled();
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
});

it('reports an error and skips the command when session creation fails', async () => {
mockConnection.sessionId = undefined;
mockSessionActions.createSession.mockRejectedValueOnce(
new Error('no session'),
);
const onToast = vi.fn();
renderApp({ onToast });
await flush();

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!echo hi');
await vi.waitFor(() => {
expect(onToast).toHaveBeenCalledWith('error', expect.any(String));
});
});

expect(mockSessionActions.sendShellCommand).not.toHaveBeenCalled();
});

it('queues a ! command during a turn and drains it when the turn ends', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!echo queued');
await Promise.resolve();
});

expect(mockSessionActions.sendShellCommand).not.toHaveBeenCalled();
expect(onToast).toHaveBeenCalledWith('info', expect.any(String));

act(() => {
testState.streamingState = 'idle';
rerender({ onToast });
});
await act(async () => {
await vi.waitFor(() => {
expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith(
'echo queued',
);
});
});
});

it('drops queued ! commands when the session changes', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!rm -rf build/');
await Promise.resolve();
});
expect(mockSessionActions.sendShellCommand).not.toHaveBeenCalled();

// Switch to a different, idle session in a single commit: the queue must be
// wiped before the drain effect runs, so the command never reaches the new
// session's daemon.
act(() => {
mockConnection.sessionId = 'session-2';
testState.streamingState = 'idle';
rerender({ onToast });
});
await flush();

expect(mockSessionActions.sendShellCommand).not.toHaveBeenCalled();
});

it('aborts remaining commands if the session changes mid-drain', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

let resolveFirst!: () => void;
const firstDone = new Promise<void>((r) => {
resolveFirst = r;
});
mockSessionActions.sendShellCommand
.mockReturnValueOnce(firstDone)
.mockResolvedValueOnce(undefined);

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!first');
testState.latestChatEditorProps?.onSubmit('!second');
await Promise.resolve();
});

// Go idle — drain starts and blocks on the pending first command.
act(() => {
testState.streamingState = 'idle';
rerender({ onToast });
});
await flush();

expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(1);

// Switch session while the first command is still running.
act(() => {
mockConnection.sessionId = 'session-2';
rerender({ onToast });
});

// Resolve the first command — the second must NOT be dispatched.
await act(async () => {
resolveFirst();
await Promise.resolve();
});

expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith('first');
});

it('drains multiple queued commands in FIFO order', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!aaa');
testState.latestChatEditorProps?.onSubmit('!bbb');
testState.latestChatEditorProps?.onSubmit('!ccc');
await Promise.resolve();
});

act(() => {
testState.streamingState = 'idle';
rerender({ onToast });
});
await act(async () => {
await vi.waitFor(() => {
expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(3);
});
});

expect(mockSessionActions.sendShellCommand).toHaveBeenNthCalledWith(
1,
'aaa',
);
expect(mockSessionActions.sendShellCommand).toHaveBeenNthCalledWith(
2,
'bbb',
);
expect(mockSessionActions.sendShellCommand).toHaveBeenNthCalledWith(
3,
'ccc',
);
});

it('continues draining after a command fails', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

mockSessionActions.sendShellCommand
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(undefined);

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!fail');
testState.latestChatEditorProps?.onSubmit('!ok');
await Promise.resolve();
});

act(() => {
testState.streamingState = 'idle';
rerender({ onToast });
});
await act(async () => {
await vi.waitFor(() => {
expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(2);
});
});

expect(mockSessionActions.sendShellCommand).toHaveBeenNthCalledWith(
1,
'fail',
);
expect(mockSessionActions.sendShellCommand).toHaveBeenNthCalledWith(
2,
'ok',
);
expect(onToast).toHaveBeenCalledWith('error', expect.any(String));
});

it('drops queued ! commands when the user cancels', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!rm -rf build/');
await Promise.resolve();
});
expect(mockSessionActions.sendShellCommand).not.toHaveBeenCalled();

// User presses Stop — the queue must be cleared before the turn goes idle.
await act(async () => {
testState.latestChatEditorProps?.onCancel?.();
await Promise.resolve();
});

act(() => {
testState.streamingState = 'idle';
rerender({ onToast });
});
await flush();

expect(mockSessionActions.sendShellCommand).not.toHaveBeenCalled();
});

it('stops draining remaining commands when the user cancels mid-drain', async () => {
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();

let resolveFirst!: () => void;
const firstDone = new Promise<void>((r) => {
resolveFirst = r;
});
mockSessionActions.sendShellCommand
.mockReturnValueOnce(firstDone)
.mockResolvedValueOnce(undefined);

act(() => {
testState.streamingState = 'responding';
rerender({ onToast });
});

await act(async () => {
testState.latestChatEditorProps?.onSubmit('!first');
testState.latestChatEditorProps?.onSubmit('!second');
await Promise.resolve();
});

// Go idle — drain starts and blocks on the pending first command.
act(() => {
testState.streamingState = 'idle';
rerender({ onToast });
});
await flush();

expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(1);

// User presses Stop while the first command is still running.
await act(async () => {
testState.latestChatEditorProps?.onCancel?.();
await Promise.resolve();
});

// Resolve the first command — the second must NOT be dispatched.
await act(async () => {
resolveFirst();
await Promise.resolve();
});

expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith('first');
});
});

describe('App session callbacks', () => {
it('submits through a disconnected session when prompt SSE restart is enabled', async () => {
mockConnection.status = 'disconnected';
Expand Down
Loading
Loading