mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
4290 lines
136 KiB
TypeScript
4290 lines
136 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as messageDeletion from '../utils/message-deletion';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { act, forwardRef, useImperativeHandle } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { DAEMON_APPROVAL_MODES } from '../test/sidebarHarness';
|
|
import {
|
|
DaemonHttpError,
|
|
GOAL_PAUSE_REASON_COMMAND,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import type { ContextUsageControls } from '../hooks/useContextUsageControls';
|
|
import { I18nProvider } from '../i18n';
|
|
import { formatDateTime } from '../utils/formatDateTime';
|
|
import {
|
|
WebShellCustomizationProvider,
|
|
type WebShellComposerToolbarRenderInfo,
|
|
type WebShellCustomization,
|
|
} from '../customization';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
const catalogController = vi.hoisted(() => ({
|
|
invalidateWorkspace: vi.fn(),
|
|
sessionCreated: vi.fn(),
|
|
promptAdmitted: vi.fn(),
|
|
promptAdmissionUncertain: vi.fn(),
|
|
renamed: vi.fn(),
|
|
turnCompleted: vi.fn(),
|
|
}));
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
let connectionState: any;
|
|
let streamingStateValue: string;
|
|
let pendingPermission: any;
|
|
let sessionHasActivePromptValue: boolean;
|
|
let queuedPromptDispatchError:
|
|
| ((text: string) => string | undefined)
|
|
| undefined;
|
|
let queuedPromptStreamingState: string | undefined;
|
|
let queuedPromptSessionHasActivePrompt: boolean | undefined;
|
|
let latestOnSubmit:
|
|
| ((
|
|
text: string,
|
|
images?: unknown,
|
|
files?: unknown,
|
|
commitAccepted?: () => void,
|
|
metadata?: unknown,
|
|
) => boolean)
|
|
| undefined;
|
|
let latestChatEditorProps: any;
|
|
type ToolApprovalTestProps = Parameters<
|
|
typeof import('./messages/ToolApproval').ToolApproval
|
|
>[0];
|
|
let latestToolApprovalProps: ToolApprovalTestProps | undefined;
|
|
let renderRealChatEditor: boolean;
|
|
let latestFollowupAccept: ((suggestion: string) => void) | undefined;
|
|
let latestMonitorDetailsOnOpen:
|
|
| ((tool: {
|
|
callId: string;
|
|
toolName: string;
|
|
status: 'completed';
|
|
}) => Promise<boolean>)
|
|
| undefined;
|
|
let sendPromptAdmit: (() => void) | undefined;
|
|
const clearFollowup = vi.fn();
|
|
const insertText = vi.fn();
|
|
const transcriptDispatch = vi.fn();
|
|
const appendLocalUserMessage = vi.fn();
|
|
const sendPrompt = vi.fn(async () => ({}) as any);
|
|
const submitPermission = vi.fn(async () => true);
|
|
const respondToPermission = vi.fn(async () => true);
|
|
const cancel = vi.fn(async () => {});
|
|
const setApprovalMode = vi.fn(async (mode: string) => ({ mode }));
|
|
const setModel = vi.fn(async () => ({}) as any);
|
|
const setReasoningEffort = vi.fn(async () => {});
|
|
const setWorkflowMode = vi.fn(async (_requested: boolean) => {});
|
|
const loadArtifacts = vi.fn(async () => ({ artifacts: [] }));
|
|
const getTasks = vi.fn();
|
|
const getWorkflowTasks = vi.fn();
|
|
const getGoal = vi.fn();
|
|
const controlGoal = vi.fn();
|
|
const readAttachment = vi.fn();
|
|
const getContextUsage = vi.fn();
|
|
const loadSession = vi.fn(async () => {});
|
|
const daemonActions = {
|
|
getContext: vi.fn(),
|
|
refreshModels: vi.fn().mockResolvedValue(undefined),
|
|
loadSession,
|
|
sendPrompt,
|
|
submitPermission,
|
|
respondToPermission,
|
|
cancel,
|
|
setApprovalMode,
|
|
setModel,
|
|
setReasoningEffort,
|
|
setWorkflowMode,
|
|
loadArtifacts,
|
|
getTasks,
|
|
getWorkflowTasks,
|
|
getGoal,
|
|
controlGoal,
|
|
readAttachment,
|
|
getContextUsage,
|
|
};
|
|
const enqueuePrompt = vi.fn(() => true);
|
|
const removeQueuedPrompt = vi.fn();
|
|
const editQueuedPrompt = vi.fn();
|
|
const sendQueuedPromptNow = vi.fn();
|
|
const editLastQueuedPrompt = vi.fn(() => false);
|
|
const clearQueuedPrompts = vi.fn(() => false);
|
|
let queuedPromptsMock: any[] = [];
|
|
let queuedTextsMock: string[] = [];
|
|
let ownerVersion = 0;
|
|
|
|
const latestComposerCoreOptions = vi.hoisted(() => ({
|
|
current: null as Record<string, unknown> | null,
|
|
}));
|
|
|
|
const paneSettings = vi.fn(async () => ({ v: 1, settings: [] }));
|
|
const workspaceByCwd = vi.fn(() => ({ workspaceSettings: paneSettings }));
|
|
const paneWorkspaceClient = { workspaceByCwd };
|
|
const subscriptionActions = {
|
|
getSubscriptionConfig: vi.fn().mockResolvedValue({ enabled: false }),
|
|
loadProviders: vi.fn().mockResolvedValue({ current: null }),
|
|
signOutSubscription: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const ownerGuard = {
|
|
capture: () => {
|
|
const captured = ownerVersion;
|
|
return { isCurrent: () => ownerVersion === captured };
|
|
},
|
|
};
|
|
|
|
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
|
DAEMON_APPROVAL_MODES,
|
|
useActions: () => daemonActions,
|
|
useConnection: () => connectionState,
|
|
useDaemonFollowupSuggestion: (options: any) => {
|
|
latestFollowupAccept = options?.onAccept;
|
|
return {
|
|
followupState: { suggestion: 'next idea', isVisible: true },
|
|
onAcceptFollowup: vi.fn(),
|
|
onDismissFollowup: vi.fn(),
|
|
clear: clearFollowup,
|
|
};
|
|
},
|
|
useStreamingState: () => streamingStateValue,
|
|
useTranscriptBlocks: () => [],
|
|
useTranscriptHistory: () => ({
|
|
hasMore: false,
|
|
loading: false,
|
|
capacityReached: false,
|
|
paginationError: false,
|
|
loadMore: vi.fn(),
|
|
release: vi.fn(),
|
|
}),
|
|
useTranscriptStore: () => ({
|
|
dispatch: transcriptDispatch,
|
|
appendLocalUserMessage,
|
|
}),
|
|
usePromptStatus: () => 'idle',
|
|
useOptionalWorkspace: () => undefined,
|
|
useWorkspaceActions: () => subscriptionActions,
|
|
useWorkspace: () => ({
|
|
capabilities: connectionState.capabilities,
|
|
client: paneWorkspaceClient,
|
|
status: connectionState.status,
|
|
workspaceCwd: '/primary',
|
|
}),
|
|
useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }),
|
|
useDaemonSessionOwnerGuard: () => ownerGuard,
|
|
}));
|
|
|
|
vi.mock('../session-catalog/session-catalog-hooks', () => ({
|
|
useSessionCatalogController: () => catalogController,
|
|
useDaemonActivePromptBridge: () => sessionHasActivePromptValue,
|
|
}));
|
|
|
|
vi.mock('../hooks/useQueuedPrompts', () => ({
|
|
useQueuedPrompts: (args: {
|
|
getPromptDispatchError?: (text: string) => string | undefined;
|
|
streamingState: string;
|
|
sessionHasActivePrompt?: boolean;
|
|
}) => {
|
|
queuedPromptDispatchError = args.getPromptDispatchError;
|
|
queuedPromptStreamingState = args.streamingState;
|
|
queuedPromptSessionHasActivePrompt = args.sessionHasActivePrompt;
|
|
return {
|
|
queuedPrompts: queuedPromptsMock,
|
|
queuedTexts: queuedTextsMock,
|
|
enqueuePrompt,
|
|
removeQueuedPrompt,
|
|
sendQueuedPromptNow,
|
|
editQueuedPrompt,
|
|
editLastQueuedPrompt,
|
|
clearQueuedPrompts,
|
|
};
|
|
},
|
|
}));
|
|
|
|
let messagesState: any[];
|
|
vi.mock('../hooks/useMessages', () => ({
|
|
useMessages: () => messagesState,
|
|
useMessagesFromBlocks: () => messagesState,
|
|
}));
|
|
|
|
vi.mock('../hooks/useAnimationFrameTranscriptBlocks', () => ({
|
|
useAnimationFrameTranscriptSnapshot: () => ({ blocks: [] }),
|
|
}));
|
|
|
|
vi.mock('../adapters/transcriptAdapter', () => ({
|
|
extractPendingPermission: () => pendingPermission,
|
|
}));
|
|
|
|
vi.mock('../monitorDetailsContext', async () => {
|
|
const React = await import('react');
|
|
return {
|
|
MonitorDetailsProvider: (props: {
|
|
onOpen: typeof latestMonitorDetailsOnOpen;
|
|
children: React.ReactNode;
|
|
}) => {
|
|
latestMonitorDetailsOnOpen = props.onOpen;
|
|
return React.createElement(React.Fragment, null, props.children);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('./TranscriptViewport', () => ({
|
|
TranscriptViewport: (props: any) => (
|
|
<div
|
|
data-testid="pane-messages"
|
|
data-approval={props.pendingApproval ? 'yes' : 'no'}
|
|
>
|
|
{props.messages.length}
|
|
<button
|
|
data-testid="pane-open-turn-output"
|
|
type="button"
|
|
onClick={() =>
|
|
props.onTurnOutputOpen?.({
|
|
id: 'artifact:turn-artifact',
|
|
kind: 'artifact',
|
|
title: 'Turn artifact',
|
|
turnId: 'turn-1',
|
|
artifactId: 'turn-artifact',
|
|
artifact: { id: 'turn-artifact', title: 'Turn artifact' },
|
|
workspaceCwd: '/w',
|
|
})
|
|
}
|
|
/>
|
|
<button
|
|
data-testid="pane-open-attachment"
|
|
type="button"
|
|
onClick={() =>
|
|
props.onAttachmentPreview?.({
|
|
name: 'data.json',
|
|
attachmentId: 'attachment-1',
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
),
|
|
}));
|
|
vi.mock('./StreamingStatus', () => ({
|
|
StreamingStatus: (props: any) => (
|
|
<div
|
|
data-testid="pane-streaming"
|
|
data-started-at={
|
|
props.startedAt === undefined ? 'none' : String(props.startedAt)
|
|
}
|
|
data-show-phrase={String(props.showPhrase)}
|
|
data-has-active-prompt={String(props.hasActivePrompt === true)}
|
|
/>
|
|
),
|
|
}));
|
|
vi.mock('../hooks/useComposerCore', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('../hooks/useComposerCore')>();
|
|
return {
|
|
...actual,
|
|
useComposerCore: (...args: Parameters<typeof actual.useComposerCore>) => {
|
|
latestComposerCoreOptions.current = args[0] as Record<string, unknown>;
|
|
const noop = vi.fn();
|
|
const fallback: ProxyHandler<object> = {
|
|
get(target, property) {
|
|
return Reflect.has(target, property)
|
|
? Reflect.get(target, property)
|
|
: noop;
|
|
},
|
|
};
|
|
return new Proxy(
|
|
{
|
|
containerRef: { current: null },
|
|
viewRef: { current: null },
|
|
handle: new Proxy({ hasAttachments: () => false }, fallback),
|
|
searchState: new Proxy(
|
|
{
|
|
searchMode: false,
|
|
searchInputRef: { current: null },
|
|
searchUiRef: { current: null },
|
|
},
|
|
fallback,
|
|
),
|
|
imageTransferHandlers: {},
|
|
pastedImages: [],
|
|
composerTags: [],
|
|
hasAttachments: false,
|
|
hasContent: false,
|
|
canSubmit: false,
|
|
pendingImageBatchCount: 0,
|
|
imageDragActive: false,
|
|
mobileComposer: null,
|
|
shellMode: false,
|
|
currentMode: 'default',
|
|
showShortcutHints: false,
|
|
disabled: false,
|
|
followupState: { isVisible: false, suggestion: '' },
|
|
slashMenu: null,
|
|
atMenu: null,
|
|
},
|
|
fallback,
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('./ChatEditor', () => ({
|
|
ChatEditor: forwardRef(function MockChatEditor(props: any, ref: any) {
|
|
latestOnSubmit = props.onSubmit;
|
|
latestChatEditorProps = props;
|
|
useImperativeHandle(ref, () => ({
|
|
insertText,
|
|
}));
|
|
if (renderRealChatEditor) {
|
|
return <RealChatEditor {...props} visibleToolbarActions={[]} />;
|
|
}
|
|
return (
|
|
<div data-web-shell-composer>
|
|
<button
|
|
data-testid="pane-submit"
|
|
onClick={() => props.onSubmit('hello there')}
|
|
>
|
|
send
|
|
</button>
|
|
<button data-testid="pane-cancel" onClick={props.onCancel}>
|
|
cancel
|
|
</button>
|
|
<button
|
|
data-testid="pane-pick-mode"
|
|
onClick={() => props.onSelectMode?.('yolo')}
|
|
>
|
|
mode
|
|
</button>
|
|
<button
|
|
data-testid="pane-pick-badmode"
|
|
onClick={() => props.onSelectMode?.('totally-bogus')}
|
|
>
|
|
badmode
|
|
</button>
|
|
<button
|
|
data-testid="pane-pick-model"
|
|
onClick={() => props.onSelectModel?.('gpt-x')}
|
|
>
|
|
model
|
|
</button>
|
|
<span data-testid="pane-running">{String(props.isRunning)}</span>
|
|
<span data-testid="pane-dialogopen">{String(props.dialogOpen)}</span>
|
|
<span data-testid="pane-toolbar">
|
|
{JSON.stringify(props.visibleToolbarActions ?? null)}
|
|
</span>
|
|
<span data-testid="pane-commands">
|
|
{String((props.commands ?? []).length)}
|
|
</span>
|
|
<span data-testid="pane-mode">{String(props.currentMode)}</span>
|
|
<span data-testid="pane-model">{String(props.currentModel)}</span>
|
|
<span data-testid="pane-models">
|
|
{JSON.stringify(props.availableModels ?? null)}
|
|
</span>
|
|
<span data-testid="pane-queued-messages">
|
|
{JSON.stringify(props.queuedMessages ?? null)}
|
|
</span>
|
|
<span data-testid="pane-followup">
|
|
{String(props.followupState?.suggestion ?? '')}
|
|
</span>
|
|
</div>
|
|
);
|
|
}),
|
|
}));
|
|
vi.mock('./QueuedPromptDisplay', () => ({
|
|
QueuedPromptDisplay: (props: any) => (
|
|
<div
|
|
data-testid="pane-queue"
|
|
data-can-mutate-mid-turn={String(props.canMutateMidTurn)}
|
|
data-can-insert-mid-turn={String(props.canInsertMidTurn)}
|
|
data-can-send-mid-turn-now={String(props.canSendMidTurnNow)}
|
|
onClick={() => props.onSendNow?.(1)}
|
|
>
|
|
{String(props.prompts.length)}
|
|
</div>
|
|
),
|
|
}));
|
|
vi.mock('./messages/ToolApproval', () => ({
|
|
ToolApproval: (props: ToolApprovalTestProps) => {
|
|
latestToolApprovalProps = props;
|
|
return (
|
|
<button
|
|
data-testid="tool-approval"
|
|
disabled={props.disabled}
|
|
data-keyboard-active={String(props.keyboardActive)}
|
|
data-plan-todos={JSON.stringify(
|
|
props.planTodos?.map((todo) => todo.id) ?? [],
|
|
)}
|
|
onClick={() => props.onConfirm(props.request.id, 'proceed')}
|
|
>
|
|
approve
|
|
</button>
|
|
);
|
|
},
|
|
}));
|
|
vi.mock('./messages/AskUserQuestion', () => ({
|
|
AskUserQuestion: (props: any) => (
|
|
<button
|
|
data-testid="ask-approval"
|
|
data-keyboard-active={String(props.keyboardActive)}
|
|
onClick={() => props.onConfirm(props.request.id, 'opt')}
|
|
>
|
|
ask
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
const RealChatEditor = (
|
|
await vi.importActual<typeof import('./ChatEditor')>('./ChatEditor')
|
|
).ChatEditor;
|
|
const { ChatPane } = await import('./ChatPane');
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
const EMPTY_CUSTOMIZATION: WebShellCustomization = {};
|
|
|
|
beforeEach(() => {
|
|
subscriptionActions.getSubscriptionConfig.mockReset();
|
|
subscriptionActions.getSubscriptionConfig.mockResolvedValue({
|
|
enabled: false,
|
|
});
|
|
subscriptionActions.loadProviders.mockReset();
|
|
subscriptionActions.loadProviders.mockResolvedValue({ current: null });
|
|
subscriptionActions.signOutSubscription.mockReset();
|
|
subscriptionActions.signOutSubscription.mockResolvedValue(undefined);
|
|
paneSettings.mockReset();
|
|
paneSettings.mockResolvedValue({ v: 1, settings: [] });
|
|
workspaceByCwd.mockClear();
|
|
connectionState = {
|
|
status: 'connected',
|
|
sessionId: 'sess-1',
|
|
displayName: 'Refactor core',
|
|
workspaceCwd: '/w',
|
|
loadingTranscript: false,
|
|
catchingUp: false,
|
|
// A loaded session normally carries a Goal snapshot; tests that exercise
|
|
// the hydration window set it back to undefined.
|
|
goalState: { v: 2, activity: 'idle', goal: null },
|
|
};
|
|
streamingStateValue = 'idle';
|
|
pendingPermission = null;
|
|
messagesState = [{ id: 'm1', role: 'user', content: 'hi' }];
|
|
latestOnSubmit = undefined;
|
|
latestChatEditorProps = undefined;
|
|
latestToolApprovalProps = undefined;
|
|
renderRealChatEditor = false;
|
|
sessionHasActivePromptValue = false;
|
|
queuedPromptStreamingState = undefined;
|
|
queuedPromptSessionHasActivePrompt = undefined;
|
|
latestComposerCoreOptions.current = null;
|
|
latestFollowupAccept = undefined;
|
|
latestMonitorDetailsOnOpen = undefined;
|
|
sendPromptAdmit = undefined;
|
|
queuedPromptsMock = [];
|
|
queuedTextsMock = [];
|
|
ownerVersion = 0;
|
|
sendPrompt.mockReset();
|
|
loadArtifacts.mockReset();
|
|
loadArtifacts.mockResolvedValue({ artifacts: [] });
|
|
getTasks.mockReset();
|
|
getWorkflowTasks.mockReset();
|
|
getGoal.mockReset();
|
|
controlGoal.mockReset();
|
|
readAttachment.mockReset();
|
|
readAttachment.mockResolvedValue({
|
|
data: 'eyJoaSI6IuS9oOWlvSJ9',
|
|
mimeType: 'application/json',
|
|
});
|
|
getContextUsage.mockReset();
|
|
getContextUsage.mockResolvedValue({
|
|
usage: { totalTokens: 1200, contextWindowSize: 8192 },
|
|
});
|
|
sendPrompt.mockImplementation(async (_text: string, options?: any) => {
|
|
sendPromptAdmit = options?.onAdmitted;
|
|
return {} as any;
|
|
});
|
|
clearFollowup.mockClear();
|
|
insertText.mockClear();
|
|
submitPermission.mockClear();
|
|
respondToPermission.mockClear();
|
|
cancel.mockClear();
|
|
setApprovalMode.mockClear();
|
|
setModel.mockClear();
|
|
setReasoningEffort.mockClear();
|
|
setWorkflowMode.mockReset().mockResolvedValue(undefined);
|
|
enqueuePrompt.mockClear();
|
|
enqueuePrompt.mockReturnValue(true);
|
|
removeQueuedPrompt.mockClear();
|
|
editQueuedPrompt.mockClear();
|
|
editLastQueuedPrompt.mockClear();
|
|
clearQueuedPrompts.mockClear();
|
|
transcriptDispatch.mockClear();
|
|
appendLocalUserMessage.mockClear();
|
|
catalogController.invalidateWorkspace.mockClear();
|
|
catalogController.promptAdmitted.mockClear();
|
|
catalogController.promptAdmissionUncertain.mockClear();
|
|
catalogController.turnCompleted.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
function render(
|
|
props: Record<string, unknown> = {},
|
|
customization: WebShellCustomization = EMPTY_CUSTOMIZATION,
|
|
): void {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() =>
|
|
root!.render(
|
|
<WebShellCustomizationProvider value={customization}>
|
|
<I18nProvider language="en">
|
|
<ChatPane {...props} />
|
|
</I18nProvider>
|
|
</WebShellCustomizationProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
function rerender(
|
|
props: Record<string, unknown> = {},
|
|
customization: WebShellCustomization = EMPTY_CUSTOMIZATION,
|
|
): void {
|
|
act(() =>
|
|
root!.render(
|
|
<WebShellCustomizationProvider value={customization}>
|
|
<I18nProvider language="en">
|
|
<ChatPane {...props} />
|
|
</I18nProvider>
|
|
</WebShellCustomizationProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
describe('pane subscription logout', () => {
|
|
it('handles logout in the pane workspace without sending an ACP prompt', async () => {
|
|
daemonActions.getContext.mockResolvedValue({
|
|
state: { _meta: { authType: 'qwen-subscription' } },
|
|
});
|
|
subscriptionActions.getSubscriptionConfig.mockResolvedValue({
|
|
enabled: true,
|
|
signedIn: true,
|
|
});
|
|
subscriptionActions.loadProviders.mockResolvedValue({
|
|
current: { authType: 'qwen-subscription' },
|
|
});
|
|
render({ workspaceCwd: '/secondary' });
|
|
await act(async () => {
|
|
latestOnSubmit!('/logout');
|
|
});
|
|
expect(daemonActions.getContext).toHaveBeenCalled();
|
|
expect(subscriptionActions.signOutSubscription).toHaveBeenCalledOnce();
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
expect(daemonActions.refreshModels).toHaveBeenCalledWith(undefined, {
|
|
reloadProviders: false,
|
|
});
|
|
expect(transcriptDispatch).not.toHaveBeenCalledWith([
|
|
expect.objectContaining({ type: 'status' }),
|
|
]);
|
|
});
|
|
|
|
it('keeps a busy pane from signing out or queuing logout', async () => {
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
await act(async () => {
|
|
expect(latestOnSubmit!('/logout')).toBe(false);
|
|
});
|
|
expect(subscriptionActions.signOutSubscription).not.toHaveBeenCalled();
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not treat a plan prompt as an account command', async () => {
|
|
subscriptionActions.getSubscriptionConfig.mockResolvedValue({
|
|
enabled: true,
|
|
});
|
|
subscriptionActions.loadProviders.mockResolvedValue({
|
|
current: { authType: 'qwen-subscription' },
|
|
});
|
|
render();
|
|
await act(async () => {
|
|
latestOnSubmit!('/plan /logout');
|
|
});
|
|
expect(subscriptionActions.signOutSubscription).not.toHaveBeenCalled();
|
|
expect(sendPrompt).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function testid(id: string): HTMLElement | null {
|
|
return container!.querySelector(`[data-testid="${id}"]`);
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((done, fail) => {
|
|
resolve = done;
|
|
reject = fail;
|
|
});
|
|
return { promise, reject, resolve };
|
|
}
|
|
|
|
describe('ChatPane', () => {
|
|
it('publishes owner-specific context controls and withdraws them on unmount', () => {
|
|
connectionState.commands = [
|
|
{ name: 'compress', source: 'builtin-command' },
|
|
];
|
|
const cleanups: ReturnType<typeof vi.fn>[] = [];
|
|
const registerContextUsageControls = vi.fn(
|
|
(_controls: ContextUsageControls) => {
|
|
const cleanup = vi.fn();
|
|
cleanups.push(cleanup);
|
|
return cleanup;
|
|
},
|
|
);
|
|
render({ registerContextUsageControls, onOpenContextUsage: vi.fn() });
|
|
expect(latestChatEditorProps.contextUsageControls).toBe(
|
|
registerContextUsageControls.mock.calls.at(-1)![0],
|
|
);
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
sessionId: connectionState.sessionId,
|
|
canCompress: true,
|
|
}),
|
|
);
|
|
pendingPermission = {
|
|
id: 'perm-1',
|
|
toolName: 'write_file',
|
|
rawInput: {},
|
|
};
|
|
rerender({ registerContextUsageControls });
|
|
expect(testid('pane-approval')).not.toBeNull();
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ canCompress: false }),
|
|
);
|
|
pendingPermission = null;
|
|
rerender({ registerContextUsageControls });
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ canCompress: true }),
|
|
);
|
|
sessionHasActivePromptValue = true;
|
|
rerender({ registerContextUsageControls });
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ canCompress: false }),
|
|
);
|
|
expect(cleanups[0]).toHaveBeenCalledOnce();
|
|
act(() => root!.unmount());
|
|
root = null;
|
|
expect(cleanups.at(-1)).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('clears the previous follow-up before submitting context compression', async () => {
|
|
connectionState.commands = [
|
|
{ name: 'compress', source: 'builtin-command' },
|
|
];
|
|
const registerContextUsageControls = vi.fn(
|
|
(_controls: ContextUsageControls) => vi.fn(),
|
|
);
|
|
const command = deferred<{ stopReason: 'cancelled' }>();
|
|
const onBeforeContextCompress = vi.fn();
|
|
sendPrompt.mockReturnValue(command.promise);
|
|
render({ registerContextUsageControls, onBeforeContextCompress });
|
|
let pending!: Promise<void>;
|
|
act(() => {
|
|
pending = registerContextUsageControls.mock.calls.at(-1)![0].compress();
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledExactlyOnceWith('/compress');
|
|
expect(clearFollowup).toHaveBeenCalledOnce();
|
|
expect(onBeforeContextCompress).toHaveBeenCalledExactlyOnceWith(
|
|
connectionState.sessionId,
|
|
);
|
|
expect(clearFollowup.mock.invocationCallOrder[0]).toBeLessThan(
|
|
sendPrompt.mock.invocationCallOrder[0],
|
|
);
|
|
expect(onBeforeContextCompress.mock.invocationCallOrder[0]).toBeLessThan(
|
|
sendPrompt.mock.invocationCallOrder[0],
|
|
);
|
|
await act(async () => {
|
|
command.resolve({ stopReason: 'cancelled' });
|
|
await pending;
|
|
});
|
|
});
|
|
|
|
it('exposes the selected pane without confusing it with a running session', () => {
|
|
const props = { isActive: true };
|
|
render(props);
|
|
expect(testid('chat-pane')?.hasAttribute('data-pane-active')).toBe(true);
|
|
expect(testid('chat-pane')?.getAttribute('aria-current')).toBe('location');
|
|
sessionHasActivePromptValue = true;
|
|
rerender(props);
|
|
expect(testid('chat-pane')?.hasAttribute('data-pane-active')).toBe(true);
|
|
expect(testid('chat-pane')?.getAttribute('aria-current')).toBe('location');
|
|
rerender();
|
|
expect(testid('chat-pane')?.hasAttribute('data-pane-active')).toBe(false);
|
|
expect(testid('chat-pane')?.hasAttribute('aria-current')).toBe(false);
|
|
});
|
|
|
|
it('reports tool and question waiting state while hidden and clears it on unmount', () => {
|
|
const onApprovalChange = vi.fn();
|
|
const props = { hidden: true, onApprovalChange };
|
|
render(props);
|
|
expect(onApprovalChange).toHaveBeenLastCalledWith(
|
|
connectionState.sessionId,
|
|
false,
|
|
);
|
|
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
|
rerender(props);
|
|
expect(onApprovalChange).toHaveBeenLastCalledWith(
|
|
connectionState.sessionId,
|
|
true,
|
|
);
|
|
pendingPermission = null;
|
|
rerender(props);
|
|
expect(onApprovalChange).toHaveBeenLastCalledWith(
|
|
connectionState.sessionId,
|
|
false,
|
|
);
|
|
pendingPermission = {
|
|
id: 'ask-1',
|
|
rawInput: { questions: [{ question: 'pick', options: [] }] },
|
|
};
|
|
rerender(props);
|
|
expect(onApprovalChange).toHaveBeenLastCalledWith(
|
|
connectionState.sessionId,
|
|
true,
|
|
);
|
|
expect(submitPermission).not.toHaveBeenCalled();
|
|
act(() => root!.unmount());
|
|
root = null;
|
|
expect(onApprovalChange).toHaveBeenLastCalledWith(
|
|
connectionState.sessionId,
|
|
false,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[false, '2026-01-02T00:00:00Z'],
|
|
[true, '2026-01-02T00:00:00Z'],
|
|
[false, undefined],
|
|
] as const)(
|
|
'reuses title details without including actions and dismisses them when hidden (multiple workspaces: %s, updatedAt: %s)',
|
|
async (multiWorkspace, updatedAt) => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const props = {
|
|
title: 'Pane details',
|
|
workspaceCwd: '/work/split-project',
|
|
sessionSummary: {
|
|
sessionId: 'session-details',
|
|
workspaceCwd: '/work/split-project',
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt,
|
|
hasActivePrompt: false,
|
|
branch: { name: 'codex/split', baseBranch: 'main' },
|
|
},
|
|
onToggleMaximize: vi.fn(),
|
|
};
|
|
if (multiWorkspace) {
|
|
connectionState.capabilities = {
|
|
features: [],
|
|
workspaces: [
|
|
{
|
|
id: 'w0',
|
|
cwd: '/work/web-shell',
|
|
primary: true,
|
|
trusted: true,
|
|
},
|
|
{
|
|
id: 'w1',
|
|
cwd: '/work/split-project',
|
|
displayName: 'Payments API',
|
|
primary: false,
|
|
trusted: true,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
sessionHasActivePromptValue = true;
|
|
render(props);
|
|
const title = container!.querySelector('[data-slot="popover-anchor"]')!;
|
|
expect(title.textContent).toBe('Pane details');
|
|
expect(title.querySelector('button')).toBeNull();
|
|
const composerFocus = document.createElement('input');
|
|
container!.append(composerFocus);
|
|
composerFocus.focus();
|
|
await act(async () => {
|
|
title.dispatchEvent(new Event('pointerover', { bubbles: true }));
|
|
vi.advanceTimersByTime(300);
|
|
});
|
|
expect(
|
|
document.querySelector('[role="dialog"]')?.textContent,
|
|
).toContain(multiWorkspace ? 'Payments API' : 'split-project');
|
|
expect(
|
|
document.querySelector('[role="dialog"]')?.textContent,
|
|
).toContain('codex/split');
|
|
expect(
|
|
document.querySelector('[role="dialog"]')?.textContent,
|
|
).toContain('Running');
|
|
expect(
|
|
document.querySelector('[role="dialog"]')?.textContent,
|
|
).toContain(formatDateTime(updatedAt ?? '2026-01-01T00:00:00Z'));
|
|
expect(document.activeElement).toBe(composerFocus);
|
|
rerender({ ...props, hidden: true });
|
|
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it('polls workflow tasks from the daemon capability, not the UI setting', async () => {
|
|
connectionState.supportedCommands = { workflowsEnabled: true };
|
|
messagesState = [
|
|
{
|
|
id: 'workflow-group',
|
|
role: 'tool_group',
|
|
tools: [
|
|
{
|
|
callId: 'workflow-call',
|
|
toolName: 'workflow',
|
|
status: 'in_progress',
|
|
args: {},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
getWorkflowTasks.mockResolvedValue({
|
|
v: 1,
|
|
sessionId: 'sess-1',
|
|
now: 1_000,
|
|
tasks: [],
|
|
});
|
|
|
|
render({ sessionWorkflowEnabled: false });
|
|
await act(async () => Promise.resolve());
|
|
|
|
expect(getWorkflowTasks).toHaveBeenCalledWith({ silent: true });
|
|
expect(getTasks).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'images',
|
|
[{ data: 'image-data', media_type: 'image/png' }],
|
|
undefined,
|
|
undefined,
|
|
],
|
|
['files', undefined, [{ name: 'notes.txt' }], undefined],
|
|
[
|
|
'input annotations',
|
|
undefined,
|
|
undefined,
|
|
{
|
|
inputAnnotations: [
|
|
{
|
|
start: 15,
|
|
end: 22,
|
|
text: '@notes',
|
|
type: 'file',
|
|
data: { path: 'notes.txt' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
])(
|
|
'rejects /goal with %s and preserves the draft',
|
|
(_kind, images, files, metadata) => {
|
|
const onError = vi.fn();
|
|
render({ onError });
|
|
let returned: boolean | undefined;
|
|
|
|
act(() => {
|
|
returned = latestOnSubmit!(
|
|
'/goal set inspect the attachment',
|
|
images,
|
|
files,
|
|
undefined,
|
|
metadata,
|
|
);
|
|
});
|
|
|
|
expect(returned).toBe(false);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
'Remove attachments before using /goal.',
|
|
);
|
|
expect(controlGoal).not.toHaveBeenCalled();
|
|
expect(transcriptDispatch).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('lets the host slash handler intercept /goal before the control plane', () => {
|
|
// The prop contract says the host handler runs before Web Shell handles a
|
|
// slash command; the main composer honours that for /goal, so the pane has
|
|
// to as well or an override silently applies on one surface only.
|
|
const onSlashCommand = vi.fn(() => true);
|
|
render({ onSlashCommand, onOpenGoals: vi.fn() });
|
|
let returned: boolean | undefined;
|
|
|
|
act(() => {
|
|
returned = latestOnSubmit!('/goal pause');
|
|
});
|
|
|
|
expect(returned).toBe(true);
|
|
expect(onSlashCommand).toHaveBeenCalled();
|
|
expect(getGoal).not.toHaveBeenCalled();
|
|
expect(controlGoal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not swallow a bare /goal when the pane has no goals view', () => {
|
|
// The side-task pane passes no `onOpenGoals`; consuming the text there
|
|
// opens nothing and shows nothing.
|
|
const onError = vi.fn();
|
|
render({ onError });
|
|
let returned: boolean | undefined;
|
|
|
|
act(() => {
|
|
returned = latestOnSubmit!('/goal');
|
|
});
|
|
|
|
expect(returned).toBe(false);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
'The goals view is not available on this surface.',
|
|
);
|
|
});
|
|
|
|
it('reports an objective-less /goal set without consuming it', () => {
|
|
const onError = vi.fn();
|
|
render({ onError, onOpenGoals: vi.fn() });
|
|
let returned: boolean | undefined;
|
|
|
|
act(() => {
|
|
returned = latestOnSubmit!('/goal set');
|
|
});
|
|
|
|
expect(returned).toBe(false);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
'/goal set requires an objective.',
|
|
);
|
|
expect(controlGoal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('offers Insert only while a turn is running', () => {
|
|
// `insertQueuedPrompt` no-ops at idle, so the affordance has to disappear
|
|
// with it rather than render a button that does nothing.
|
|
queuedPromptsMock = [{ id: 1, text: 'held while the Goal runs' } as never];
|
|
connectionState.goalState = {
|
|
v: 2,
|
|
activity: 'idle',
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 1,
|
|
objective: 'ship it',
|
|
status: 'active',
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
streamingStateValue = 'idle';
|
|
render();
|
|
|
|
expect(testid('pane-queue')?.dataset['canInsertMidTurn']).toBe('false');
|
|
|
|
act(() => {
|
|
sessionHasActivePromptValue = true;
|
|
rerender();
|
|
});
|
|
|
|
expect(testid('pane-queue')?.dataset['canInsertMidTurn']).toBe('true');
|
|
|
|
act(() => {
|
|
sessionHasActivePromptValue = false;
|
|
streamingStateValue = 'responding';
|
|
rerender();
|
|
});
|
|
|
|
expect(testid('pane-queue')?.dataset['canInsertMidTurn']).toBe('true');
|
|
});
|
|
|
|
it('preserves a /goal command the pane connection cannot deliver', () => {
|
|
// App.tsx applies the broken-connection guard before any slash handling and
|
|
// keeps the text in the composer. Without the same ordering here the branch
|
|
// consumes the text, writes a transcript entry, and only then fails inside
|
|
// `requireSessionForAction` — the typed control is gone.
|
|
const onError = vi.fn();
|
|
connectionState = { ...connectionState, status: 'error' };
|
|
render({ onError });
|
|
let returned: boolean | undefined;
|
|
|
|
act(() => {
|
|
returned = latestOnSubmit!('/goal pause');
|
|
});
|
|
|
|
expect(returned).toBe(false);
|
|
expect(controlGoal).not.toHaveBeenCalled();
|
|
expect(getGoal).not.toHaveBeenCalled();
|
|
expect(appendLocalUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps goal controls locked when the goal is replaced mid-control', async () => {
|
|
const goalA = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-a',
|
|
revision: 5,
|
|
objective: 'ship it',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const goalB = {
|
|
...goalA,
|
|
goal: {
|
|
...goalA.goal,
|
|
goalId: 'goal-b',
|
|
revision: 1,
|
|
objective: 'replaced by another client',
|
|
updatedAt: 2,
|
|
},
|
|
};
|
|
const pendingControl = deferred<{ snapshot: typeof goalA }>();
|
|
connectionState.goalState = goalA;
|
|
getGoal.mockResolvedValue({ snapshot: goalA });
|
|
controlGoal.mockReturnValueOnce(pendingControl.promise);
|
|
render();
|
|
|
|
const pause = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
);
|
|
if (!pause) throw new Error('pause control was not rendered');
|
|
act(() => pause.click());
|
|
await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledOnce());
|
|
|
|
// Another client replaces the goal while the pause is still in flight.
|
|
act(() => {
|
|
connectionState = { ...connectionState, goalState: goalB };
|
|
rerender();
|
|
});
|
|
const pauseAfterReplace = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
);
|
|
expect(pauseAfterReplace?.disabled).toBe(true);
|
|
act(() => pauseAfterReplace?.click());
|
|
expect(controlGoal).toHaveBeenCalledOnce();
|
|
|
|
await act(async () => pendingControl.resolve({ snapshot: goalB }));
|
|
expect(
|
|
container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
)?.disabled,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('locks goal controls while the current snapshot refresh is in flight', async () => {
|
|
const current = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 5,
|
|
objective: 'ship it',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
connectionState.goalState = current;
|
|
let resolveGoal:
|
|
| ((value: { snapshot: typeof current }) => void)
|
|
| undefined;
|
|
getGoal.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveGoal = resolve;
|
|
}),
|
|
);
|
|
controlGoal.mockResolvedValue({ snapshot: current });
|
|
render();
|
|
|
|
const pause = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
);
|
|
if (!pause) throw new Error('pause control was not rendered');
|
|
act(() => pause.click());
|
|
|
|
expect(pause.disabled).toBe(true);
|
|
act(() => pause.click());
|
|
expect(getGoal).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
resolveGoal?.({ snapshot: current });
|
|
});
|
|
expect(controlGoal).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('builds the control request from the freshly fetched Goal', async () => {
|
|
// `expectedGoalId`/`expectedRevision` must come from the getGoal round trip,
|
|
// not from the possibly-stale snapshot in connection state, or every
|
|
// control races the daemon's CAS.
|
|
const stale = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 5,
|
|
objective: 'ship it',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const fresh = {
|
|
...stale,
|
|
goal: { ...stale.goal, revision: 9 },
|
|
};
|
|
connectionState.goalState = stale;
|
|
getGoal.mockResolvedValue({ snapshot: fresh });
|
|
controlGoal.mockResolvedValue({ snapshot: fresh });
|
|
render({ onOpenGoals: vi.fn() });
|
|
|
|
act(() => {
|
|
container!
|
|
.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
)!
|
|
.click();
|
|
});
|
|
await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledTimes(1));
|
|
|
|
expect(controlGoal).toHaveBeenCalledWith({
|
|
action: 'pause',
|
|
expectedGoalId: 'goal-1',
|
|
expectedRevision: 9,
|
|
reason: GOAL_PAUSE_REASON_COMMAND,
|
|
});
|
|
|
|
// `/goal set` maps to a versioned replace against the same fresh snapshot.
|
|
act(() => {
|
|
latestOnSubmit!('/goal set ship the other thing');
|
|
});
|
|
await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledTimes(2));
|
|
expect(controlGoal).toHaveBeenLastCalledWith({
|
|
action: 'replace',
|
|
objective: 'ship the other thing',
|
|
expectedGoalId: 'goal-1',
|
|
expectedRevision: 9,
|
|
});
|
|
expect(appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'/goal set ship the other thing',
|
|
undefined,
|
|
{ localCommandId: expect.stringMatching(/^local-command:/) },
|
|
);
|
|
});
|
|
|
|
it('closes the pane Goal edit dialog when its session changes', async () => {
|
|
// Left open, the dialog re-syncs its textarea from the new session's
|
|
// objective and the user edits that Goal believing it is the old one.
|
|
const goalA = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-a',
|
|
revision: 5,
|
|
objective: 'session A objective',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
connectionState.goalState = goalA;
|
|
getGoal.mockResolvedValue({ snapshot: goalA });
|
|
render();
|
|
|
|
act(() => {
|
|
container!
|
|
.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
|
)!
|
|
.click();
|
|
});
|
|
expect(document.querySelector('textarea')).not.toBeNull();
|
|
|
|
act(() => {
|
|
connectionState = {
|
|
...connectionState,
|
|
goalState: {
|
|
...goalA,
|
|
goal: { ...goalA.goal, goalId: 'goal-b', objective: 'goal B' },
|
|
},
|
|
};
|
|
rerender();
|
|
});
|
|
|
|
expect(document.querySelector('textarea')).toBeNull();
|
|
});
|
|
|
|
it('does not dispatch a Goal control after the pane session changes during refresh', async () => {
|
|
const current = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 5,
|
|
objective: 'ship it',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const pendingGoal = deferred<{ snapshot: typeof current }>();
|
|
const onError = vi.fn();
|
|
connectionState.goalState = current;
|
|
getGoal.mockReturnValueOnce(pendingGoal.promise);
|
|
render({ onError });
|
|
|
|
const pause = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
);
|
|
if (!pause) throw new Error('pause control was not rendered');
|
|
act(() => pause.click());
|
|
act(() => {
|
|
ownerVersion += 1;
|
|
connectionState = { ...connectionState, sessionId: 'sess-2' };
|
|
rerender({ onError });
|
|
});
|
|
await act(async () => pendingGoal.resolve({ snapshot: current }));
|
|
|
|
expect(controlGoal).not.toHaveBeenCalled();
|
|
// The operation was dropped on purpose; reporting it would show a failure
|
|
// toast for a control the user's own session switch cancelled.
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('releases Goal control busy state after a same-session reattach', async () => {
|
|
const current = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 5,
|
|
objective: 'ship it',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const pendingControl = deferred<{ snapshot: typeof current }>();
|
|
connectionState.goalState = current;
|
|
getGoal.mockResolvedValue({ snapshot: current });
|
|
controlGoal.mockReturnValueOnce(pendingControl.promise);
|
|
render();
|
|
|
|
const pause = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
);
|
|
if (!pause) throw new Error('pause control was not rendered');
|
|
act(() => pause.click());
|
|
await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledOnce());
|
|
act(() => {
|
|
ownerVersion += 1;
|
|
rerender();
|
|
});
|
|
await act(async () => pendingControl.resolve({ snapshot: current }));
|
|
|
|
expect(
|
|
container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Pause goal"]',
|
|
)?.disabled,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('reports an edit failure after the edited Goal disappears', async () => {
|
|
const current = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 5,
|
|
objective: 'ship it',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const pendingGoal = deferred<{
|
|
snapshot: { v: 2; activity: 'idle'; goal: null };
|
|
}>();
|
|
const onError = vi.fn();
|
|
connectionState.goalState = current;
|
|
getGoal.mockReturnValueOnce(pendingGoal.promise);
|
|
render({ onError });
|
|
|
|
act(() => {
|
|
container!
|
|
.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
|
)
|
|
?.click();
|
|
});
|
|
const save = [
|
|
...document.querySelectorAll<HTMLButtonElement>('button'),
|
|
].find((button) => button.textContent === 'Save');
|
|
if (!save) throw new Error('save control was not rendered');
|
|
act(() => save.click());
|
|
act(() => {
|
|
connectionState = {
|
|
...connectionState,
|
|
goalState: { v: 2, activity: 'idle', goal: null },
|
|
};
|
|
rerender({ onError });
|
|
});
|
|
await act(async () =>
|
|
pendingGoal.resolve({
|
|
snapshot: { v: 2, activity: 'idle', goal: null },
|
|
}),
|
|
);
|
|
|
|
expect(onError).toHaveBeenCalledWith(
|
|
// The guard that produces this message is the only protection the
|
|
// pause/resume/clear flows have against dereferencing a null goal, so
|
|
// pin the message rather than "some Error".
|
|
expect.objectContaining({ message: 'The goal is no longer available.' }),
|
|
'Failed to edit the goal',
|
|
);
|
|
});
|
|
|
|
it.each(['resolve', 'reject'] as const)(
|
|
'ignores a stale Goal edit %s after the pane session changes',
|
|
async (outcome) => {
|
|
const goalA = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-a',
|
|
revision: 5,
|
|
objective: 'session A objective',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-a' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const goalB = {
|
|
...goalA,
|
|
goal: {
|
|
...goalA.goal,
|
|
goalId: 'goal-b',
|
|
revision: 1,
|
|
objective: 'session B objective',
|
|
},
|
|
};
|
|
let resolveEdit!: (value: { snapshot: typeof goalA }) => void;
|
|
let rejectEdit!: (error: Error) => void;
|
|
const edit = new Promise<{ snapshot: typeof goalA }>(
|
|
(resolve, reject) => {
|
|
resolveEdit = resolve;
|
|
rejectEdit = reject;
|
|
},
|
|
);
|
|
connectionState.goalState = goalA;
|
|
getGoal.mockResolvedValue({ snapshot: goalA });
|
|
controlGoal.mockReturnValueOnce(edit);
|
|
render();
|
|
|
|
const editA = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
|
);
|
|
if (!editA) throw new Error('session A edit control was not rendered');
|
|
act(() => editA.click());
|
|
const saveA = [
|
|
...document.querySelectorAll<HTMLButtonElement>('button'),
|
|
].find((button) => button.textContent === 'Save');
|
|
if (!saveA) throw new Error('session A save control was not rendered');
|
|
act(() => saveA.click());
|
|
await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledTimes(1));
|
|
|
|
act(() => {
|
|
ownerVersion += 1;
|
|
connectionState = {
|
|
...connectionState,
|
|
sessionId: 'sess-2',
|
|
goalState: goalB,
|
|
};
|
|
rerender();
|
|
});
|
|
const editB = container!.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
|
);
|
|
if (!editB) throw new Error('session B edit control was not rendered');
|
|
expect(editB.disabled).toBe(false);
|
|
act(() => editB.click());
|
|
expect(document.querySelector('textarea')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
if (outcome === 'resolve') resolveEdit({ snapshot: goalA });
|
|
else rejectEdit(new Error('session A edit failed'));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(document.querySelector('textarea')).not.toBeNull();
|
|
expect(document.querySelector('[role="alert"]')).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('rejects a Goal edit when the same session replaces the goal', async () => {
|
|
const goalA = {
|
|
v: 2 as const,
|
|
activity: 'running' as const,
|
|
goal: {
|
|
goalId: 'goal-a',
|
|
revision: 5,
|
|
objective: 'goal A',
|
|
status: 'active' as const,
|
|
evidenceCursor: { recordId: 'record-a' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const goalB = {
|
|
...goalA,
|
|
goal: { ...goalA.goal, goalId: 'goal-b', objective: 'goal B' },
|
|
};
|
|
const pendingGoal = deferred<{ snapshot: typeof goalB }>();
|
|
const onError = vi.fn();
|
|
connectionState.goalState = goalA;
|
|
getGoal.mockReturnValueOnce(pendingGoal.promise);
|
|
render({ onError });
|
|
|
|
act(() => {
|
|
container!
|
|
.querySelector<HTMLButtonElement>(
|
|
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
|
)
|
|
?.click();
|
|
});
|
|
const save = [
|
|
...document.querySelectorAll<HTMLButtonElement>('button'),
|
|
].find((button) => button.textContent === 'Save');
|
|
if (!save) throw new Error('save control was not rendered');
|
|
act(() => save.click());
|
|
act(() => {
|
|
connectionState = { ...connectionState, goalState: goalB };
|
|
rerender({ onError });
|
|
});
|
|
await act(async () => pendingGoal.resolve({ snapshot: goalB }));
|
|
|
|
expect(controlGoal).not.toHaveBeenCalled();
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
'Failed to edit the goal',
|
|
);
|
|
});
|
|
|
|
it('opens a pane monitor in the shared right panel', async () => {
|
|
connectionState.capabilities = {
|
|
features: ['session_monitor_tool_correlation'],
|
|
};
|
|
const monitor = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch pane logs',
|
|
status: 'running',
|
|
startTime: 1,
|
|
runtimeMs: 10,
|
|
command: 'tail -f pane.log',
|
|
eventCount: 1,
|
|
droppedLines: 0,
|
|
toolUseId: 'monitor-call',
|
|
};
|
|
getTasks.mockResolvedValue({
|
|
v: 1,
|
|
sessionId: 'sess-1',
|
|
now: 11,
|
|
tasks: [monitor],
|
|
});
|
|
const onOpenMonitor = vi.fn();
|
|
|
|
render({ onOpenMonitor });
|
|
|
|
let opened = false;
|
|
await act(async () => {
|
|
opened =
|
|
(await latestMonitorDetailsOnOpen?.({
|
|
callId: 'monitor-call',
|
|
toolName: 'monitor',
|
|
status: 'completed',
|
|
})) ?? false;
|
|
});
|
|
|
|
expect(opened).toBe(true);
|
|
expect(getTasks).toHaveBeenCalledOnce();
|
|
expect(onOpenMonitor).toHaveBeenCalledWith(
|
|
monitor,
|
|
'sess-1',
|
|
daemonActions,
|
|
);
|
|
});
|
|
|
|
it('does not open a monitor returned for another pane session', async () => {
|
|
connectionState.capabilities = {
|
|
features: ['session_monitor_tool_correlation'],
|
|
};
|
|
getTasks.mockResolvedValue({
|
|
v: 1,
|
|
sessionId: 'other-session',
|
|
now: 11,
|
|
tasks: [],
|
|
});
|
|
const onOpenMonitor = vi.fn();
|
|
|
|
render({ onOpenMonitor });
|
|
|
|
let opened = true;
|
|
await act(async () => {
|
|
opened =
|
|
(await latestMonitorDetailsOnOpen?.({
|
|
callId: 'monitor-call',
|
|
toolName: 'monitor',
|
|
status: 'completed',
|
|
})) ?? false;
|
|
});
|
|
|
|
expect(opened).toBe(false);
|
|
expect(onOpenMonitor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders the custom composer footer directly after the pane editor', () => {
|
|
const footerProps: WebShellComposerToolbarRenderInfo[] = [];
|
|
const ComposerFooter = (props: WebShellComposerToolbarRenderInfo) => {
|
|
footerProps.push(props);
|
|
return <div data-testid="pane-composer-footer">pane footer</div>;
|
|
};
|
|
|
|
render({}, { renderComposerFooter: ComposerFooter });
|
|
|
|
const composer = container!.querySelector('[data-web-shell-composer]');
|
|
const footer = testid('pane-composer-footer');
|
|
expect(composer?.nextElementSibling).toBe(footer);
|
|
expect(footer?.parentElement).toBe(composer?.parentElement);
|
|
expect(footerProps.at(-1)).toEqual({
|
|
disabled: false,
|
|
isRunning: false,
|
|
currentMode: 'default',
|
|
currentModel: '',
|
|
sessionName: 'Refactor core',
|
|
});
|
|
});
|
|
|
|
it('updates the custom composer footer with pane-scoped state', () => {
|
|
const footerProps: WebShellComposerToolbarRenderInfo[] = [];
|
|
const ComposerFooter = (props: WebShellComposerToolbarRenderInfo) => {
|
|
footerProps.push(props);
|
|
return <div data-testid="pane-composer-footer" />;
|
|
};
|
|
const customization = { renderComposerFooter: ComposerFooter };
|
|
render({}, customization);
|
|
|
|
streamingStateValue = 'responding';
|
|
connectionState.catchingUp = true;
|
|
connectionState.currentMode = 'plan';
|
|
connectionState.currentModel = 'qwen-next';
|
|
connectionState.displayName = 'Pane Two';
|
|
rerender({}, customization);
|
|
|
|
expect(footerProps.at(-1)).toEqual({
|
|
disabled: false,
|
|
isRunning: true,
|
|
currentMode: 'plan',
|
|
currentModel: 'qwen-next',
|
|
sessionName: 'Pane Two',
|
|
});
|
|
|
|
connectionState.catchingUp = false;
|
|
pendingPermission = {
|
|
id: 'perm-1',
|
|
toolName: 'write_file',
|
|
rawInput: {},
|
|
};
|
|
rerender({}, customization);
|
|
|
|
expect(latestChatEditorProps.disabled).toBe(true);
|
|
expect(footerProps.at(-1)?.disabled).toBe(true);
|
|
});
|
|
|
|
it('hides the pane composer while an approval is pending', () => {
|
|
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
|
render();
|
|
expect(testid('pane-approval')).not.toBeNull();
|
|
expect(latestToolApprovalProps?.showBrowserNotifications).toBe(true);
|
|
// The streaming status and the editor share the approval-hidden wrapper,
|
|
// so neither lingers below the dialog.
|
|
expect(testid('pane-streaming')?.parentElement?.className).toContain(
|
|
'composerHidden',
|
|
);
|
|
expect(
|
|
container!.querySelector('[data-web-shell-composer]')?.parentElement
|
|
?.className,
|
|
).toContain('composerHidden');
|
|
});
|
|
|
|
it.each([undefined, 'yolo'])(
|
|
'uses only the reported Plan execution policy for approval: %s',
|
|
(planExecutionMode) => {
|
|
connectionState.currentMode = 'plan';
|
|
connectionState.planExecutionMode = planExecutionMode;
|
|
pendingPermission = {
|
|
id: 'plan-approval',
|
|
toolName: 'exit_plan_mode',
|
|
toolKind: 'switch_mode',
|
|
options: [],
|
|
};
|
|
render();
|
|
expect(latestToolApprovalProps?.planExecutionMode).toBe(
|
|
planExecutionMode,
|
|
);
|
|
},
|
|
);
|
|
|
|
it('hides the composer during plan approval and restores it after resolution', () => {
|
|
connectionState.currentMode = 'plan';
|
|
connectionState.planExecutionMode = 'default';
|
|
pendingPermission = {
|
|
id: 'perm-plan',
|
|
toolName: 'exit_plan_mode',
|
|
toolKind: 'switch_mode',
|
|
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
|
content: [],
|
|
};
|
|
render();
|
|
const composerWrapper = () =>
|
|
container!.querySelector('[data-web-shell-composer]')?.parentElement;
|
|
expect(composerWrapper()?.className).toContain('composerHidden');
|
|
expect(testid('pane-approval')).not.toBeNull();
|
|
expect(submitPermission).not.toHaveBeenCalled();
|
|
|
|
pendingPermission = null;
|
|
rerender();
|
|
expect(composerWrapper()?.className).not.toContain('composerHidden');
|
|
expect(testid('pane-approval')).toBeNull();
|
|
});
|
|
|
|
it('restores the pane composer after the approval resolves', () => {
|
|
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
|
render();
|
|
expect(
|
|
container!.querySelector('[data-web-shell-composer]')?.parentElement
|
|
?.className,
|
|
).toContain('composerHidden');
|
|
|
|
pendingPermission = null;
|
|
rerender();
|
|
expect(testid('pane-approval')).toBeNull();
|
|
expect(
|
|
container!.querySelector('[data-web-shell-composer]')?.parentElement
|
|
?.className,
|
|
).not.toContain('composerHidden');
|
|
});
|
|
|
|
it('adds no composer footer DOM when omitted or returning null', () => {
|
|
render();
|
|
const composer = container!.querySelector('[data-web-shell-composer]');
|
|
const children = Array.from(composer?.parentElement?.children ?? []);
|
|
expect(composer?.nextElementSibling).toBeNull();
|
|
expect(latestChatEditorProps.disabled).toBe(false);
|
|
|
|
rerender({}, { renderComposerFooter: () => null });
|
|
|
|
const nullComposer = container!.querySelector('[data-web-shell-composer]');
|
|
expect(nullComposer?.nextElementSibling).toBeNull();
|
|
expect(
|
|
Array.from(nullComposer?.parentElement?.children ?? []),
|
|
).toHaveLength(children.length);
|
|
});
|
|
|
|
it('renders the session transcript and header label', () => {
|
|
render({ title: 'Refactor core' });
|
|
expect(testid('pane-messages')?.textContent).toBe('1');
|
|
expect(container!.textContent).toContain('Refactor core');
|
|
});
|
|
|
|
it('omits its frame header when embedded in another panel', () => {
|
|
render({ title: 'Side task', embedded: true });
|
|
expect(container!.querySelector('header')).toBeNull();
|
|
expect(testid('pane-messages')).not.toBeNull();
|
|
});
|
|
|
|
it('threads unavailable models onto its composer', () => {
|
|
const unavailableModels = [
|
|
{ id: 'qwen-max', label: 'Qwen Max', tooltip: 'Need Pro' },
|
|
];
|
|
const mountUnavailableModelTooltip = () => null;
|
|
render({ unavailableModels, mountUnavailableModelTooltip });
|
|
expect(latestChatEditorProps.unavailableModels).toEqual(unavailableModels);
|
|
expect(latestChatEditorProps.mountUnavailableModelTooltip).toBe(
|
|
mountUnavailableModelTooltip,
|
|
);
|
|
});
|
|
|
|
it('shows the Plan entry only when explicitly enabled and hides it on removal', () => {
|
|
render();
|
|
expect(latestChatEditorProps.visibleToolbarActions).not.toContain('plan');
|
|
rerender({ planControlVisible: true });
|
|
expect(latestChatEditorProps.visibleToolbarActions).toContain('plan');
|
|
rerender();
|
|
expect(latestChatEditorProps.visibleToolbarActions).not.toContain('plan');
|
|
});
|
|
|
|
it('adds no workspace toolbar chip on a single-workspace daemon', () => {
|
|
render({ title: 'Refactor core', workspaceCwd: '/w' });
|
|
expect(latestChatEditorProps.visibleToolbarActions).toContain('addMenu');
|
|
expect(latestChatEditorProps.visibleToolbarActions).not.toContain(
|
|
'workspace',
|
|
);
|
|
expect(latestChatEditorProps.workspaceName).toBeUndefined();
|
|
});
|
|
|
|
it('does not thread host at mention props onto ChatEditor', () => {
|
|
render(
|
|
{ title: 'Refactor core' },
|
|
{
|
|
atProviders: [
|
|
{
|
|
id: 'tables',
|
|
label: 'Tables',
|
|
async search() {
|
|
return [];
|
|
},
|
|
},
|
|
],
|
|
builtinAtProviders: { exclude: ['extensions'] },
|
|
},
|
|
);
|
|
expect(latestChatEditorProps.atProviders).toBeUndefined();
|
|
expect(latestChatEditorProps.builtinAtProviders).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the workspace in the split header and only shows the toolbar chip when embedded', () => {
|
|
connectionState.capabilities = {
|
|
features: [],
|
|
workspaceCwd: '/work/web-shell',
|
|
workspaces: [
|
|
{ id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true },
|
|
{
|
|
id: 'w1',
|
|
cwd: '/work/api',
|
|
displayName: 'Payments API',
|
|
primary: false,
|
|
trusted: true,
|
|
},
|
|
],
|
|
};
|
|
// The split view hands each pane its own workspace explicitly.
|
|
render({ title: 'Add pagination', workspaceCwd: '/work/api' });
|
|
expect(latestChatEditorProps.visibleToolbarActions).not.toContain(
|
|
'workspace',
|
|
);
|
|
expect(
|
|
container!.querySelector('[data-web-shell-pane-workspace]')?.textContent,
|
|
).toContain('Payments API');
|
|
|
|
rerender({
|
|
title: 'Add pagination',
|
|
workspaceCwd: '/work/api',
|
|
embedded: true,
|
|
});
|
|
expect(container!.querySelector('header')).toBeNull();
|
|
expect(latestChatEditorProps.visibleToolbarActions).toContain('workspace');
|
|
expect(latestChatEditorProps.workspaceName).toBe('Payments API');
|
|
expect(latestChatEditorProps.workspaceTitle).toBe('/work/api');
|
|
// The chip carries the pane's stable accent color (api is the 2nd workspace
|
|
// → the 2nd palette color) so it stays distinct when it collapses to an icon.
|
|
expect(latestChatEditorProps.workspaceColor).toBe('green');
|
|
});
|
|
|
|
it('binds split Voice to the connected secondary workspace and revision', () => {
|
|
connectionState.workspaceCwd = '/work/api';
|
|
connectionState.capabilities = {
|
|
features: ['workspace_qualified_voice'],
|
|
workspaceCwd: '/work/web-shell',
|
|
workspaces: [
|
|
{ id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true },
|
|
{ id: 'w1', cwd: '/work/api', primary: false, trusted: true },
|
|
],
|
|
};
|
|
|
|
render({
|
|
workspaceCwd: '/work/api',
|
|
voiceUserRevision: 3,
|
|
voiceWorkspaceRevisions: {
|
|
'["workspace-qualified","id","w1","/work/api"]': 5,
|
|
},
|
|
});
|
|
|
|
expect(latestChatEditorProps.voiceTarget).toMatchObject({
|
|
route: 'workspace-qualified',
|
|
cwd: '/work/api',
|
|
selector: { kind: 'id', value: 'w1' },
|
|
sessionId: 'sess-1',
|
|
streamPath: 'workspaces/w1/voice/stream',
|
|
});
|
|
expect(latestChatEditorProps.voiceStatusRevision).toEqual({
|
|
user: 3,
|
|
workspace: 5,
|
|
});
|
|
});
|
|
|
|
it('binds split Voice to the legacy primary workspace without a workspace list', () => {
|
|
connectionState.capabilities = {
|
|
features: ['voice_transcribe'],
|
|
workspaceCwd: '/w',
|
|
};
|
|
|
|
render({ workspaceCwd: '/w' });
|
|
|
|
expect(latestChatEditorProps.voiceTarget).toMatchObject({
|
|
route: 'legacy-primary',
|
|
cwd: '/w',
|
|
sessionId: 'sess-1',
|
|
streamPath: 'voice/stream',
|
|
});
|
|
});
|
|
|
|
it('keeps an active split Voice owner stable while approval is pending', () => {
|
|
connectionState.capabilities = {
|
|
features: ['voice_transcribe'],
|
|
workspaceCwd: '/w',
|
|
};
|
|
render({ workspaceCwd: '/w' });
|
|
const voiceTarget = latestChatEditorProps.voiceTarget;
|
|
const voiceStatusRevision = latestChatEditorProps.voiceStatusRevision;
|
|
expect(voiceTarget).toBeDefined();
|
|
|
|
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
|
rerender({ workspaceCwd: '/w' });
|
|
|
|
expect(latestChatEditorProps.dialogOpen).toBe(true);
|
|
expect(latestChatEditorProps.disabled).toBe(true);
|
|
expect(latestChatEditorProps.voiceTarget).toBe(voiceTarget);
|
|
expect(latestChatEditorProps.voiceStatusRevision).toBe(voiceStatusRevision);
|
|
});
|
|
|
|
it('uses the merged registered workspace list for split Voice', () => {
|
|
connectionState.workspaceCwd = '/work/locked';
|
|
connectionState.capabilities = {
|
|
features: ['workspace_qualified_voice'],
|
|
workspaceCwd: '/work/web-shell',
|
|
workspaces: [
|
|
{ id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true },
|
|
],
|
|
};
|
|
|
|
render({
|
|
workspaceCwd: '/work/locked',
|
|
voiceWorkspaces: [
|
|
{ id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true },
|
|
{
|
|
id: 'locked',
|
|
cwd: '/work/locked',
|
|
primary: false,
|
|
trusted: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(latestChatEditorProps.voiceTarget).toMatchObject({
|
|
route: 'workspace-qualified',
|
|
cwd: '/work/locked',
|
|
selector: { kind: 'id', value: 'locked' },
|
|
streamPath: 'workspaces/locked/voice/stream',
|
|
});
|
|
});
|
|
|
|
it('fails closed on split workspace mismatch and while hidden', () => {
|
|
connectionState.workspaceCwd = '/work/api';
|
|
connectionState.capabilities = {
|
|
features: ['workspace_qualified_voice'],
|
|
workspaceCwd: '/work/web-shell',
|
|
workspaces: [
|
|
{ id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true },
|
|
{ id: 'w1', cwd: '/work/api', primary: false, trusted: true },
|
|
],
|
|
};
|
|
|
|
render({ workspaceCwd: '/work/other' });
|
|
expect(latestChatEditorProps.voiceTarget).toBeUndefined();
|
|
|
|
rerender({ workspaceCwd: '/work/api', hidden: true });
|
|
expect(latestChatEditorProps.voiceTarget).toBeUndefined();
|
|
});
|
|
|
|
it('surfaces the workspace in the pane header on a multi-workspace daemon', () => {
|
|
connectionState.capabilities = {
|
|
features: [],
|
|
workspaceCwd: '/work/web-shell',
|
|
workspaces: [
|
|
{ id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true },
|
|
{
|
|
id: 'w1',
|
|
cwd: '/work/api',
|
|
displayName: 'Payments API',
|
|
primary: false,
|
|
trusted: true,
|
|
},
|
|
],
|
|
};
|
|
render({ title: 'Add pagination', workspaceCwd: '/work/api' });
|
|
// The header tag (always visible at the top, unlike the composer chip that
|
|
// collapses on a narrow split) names the workspace and carries its full cwd
|
|
// in a hover tooltip.
|
|
const tag = container!.querySelector('[data-web-shell-pane-workspace]');
|
|
expect(tag).not.toBeNull();
|
|
expect(tag!.textContent).toContain('Payments API');
|
|
expect(tag!.getAttribute('title')).toBe('/work/api');
|
|
});
|
|
|
|
it('omits the header workspace tag on a single-workspace daemon', () => {
|
|
render({ title: 'Refactor core', workspaceCwd: '/w' });
|
|
expect(
|
|
container!.querySelector('[data-web-shell-pane-workspace]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('reports loaded pane artifacts to the outer panel owner', async () => {
|
|
const onPaneArtifactsChange = vi.fn();
|
|
connectionState.capabilities = { features: ['session_artifacts'] };
|
|
const artifact = {
|
|
id: 'artifact-1',
|
|
title: 'Report',
|
|
kind: 'html',
|
|
storage: 'workspace',
|
|
workspacePath: 'reports/a.html',
|
|
updatedAt: '2026-07-10T00:00:00Z',
|
|
};
|
|
loadArtifacts.mockResolvedValueOnce({ artifacts: [artifact] });
|
|
|
|
render({ onPaneArtifactsChange });
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onPaneArtifactsChange).toHaveBeenLastCalledWith('sess-1', [
|
|
artifact,
|
|
]);
|
|
});
|
|
|
|
it('stamps its session identity on turn-output open requests', () => {
|
|
const onRightPanelOpen = vi.fn();
|
|
render({ onRightPanelOpen });
|
|
|
|
act(() => {
|
|
testid('pane-open-turn-output')?.click();
|
|
});
|
|
|
|
expect(onRightPanelOpen).toHaveBeenCalledWith({
|
|
id: 'artifact:turn-artifact',
|
|
kind: 'artifact',
|
|
title: 'Turn artifact',
|
|
turnId: 'turn-1',
|
|
artifactId: 'turn-artifact',
|
|
artifact: { id: 'turn-artifact', title: 'Turn artifact' },
|
|
workspaceCwd: '/w',
|
|
sourceSessionId: 'sess-1',
|
|
});
|
|
});
|
|
|
|
it('reads daemon attachments through the pane session before previewing', async () => {
|
|
const onRightPanelOpen = vi.fn();
|
|
render({ onRightPanelOpen });
|
|
|
|
await act(async () => {
|
|
testid('pane-open-attachment')?.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(readAttachment).toHaveBeenCalledWith('attachment-1');
|
|
expect(onRightPanelOpen).toHaveBeenCalledWith({
|
|
id: 'attachment:attachment-1',
|
|
kind: 'attachment',
|
|
title: 'data.json',
|
|
turnId: 'sess-1',
|
|
attachmentId: 'attachment-1',
|
|
mimeType: 'application/json',
|
|
data: expect.any(Blob),
|
|
workspaceCwd: '/w',
|
|
sourceSessionId: 'sess-1',
|
|
});
|
|
});
|
|
|
|
it('suppresses the rotating loading phrase in its compact status', () => {
|
|
render();
|
|
expect(testid('pane-streaming')?.getAttribute('data-show-phrase')).toBe(
|
|
'false',
|
|
);
|
|
});
|
|
|
|
it('sends an idle prompt directly so the pane enters loading immediately', () => {
|
|
render();
|
|
act(() =>
|
|
testid('pane-submit')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
expect(sendPrompt).toHaveBeenCalledWith('hello there', {
|
|
submittedPrompt: 'hello there',
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
expect(clearFollowup).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends an idle prompt while the Goal state is still hydrating', () => {
|
|
connectionState = { ...connectionState, goalState: undefined };
|
|
render();
|
|
|
|
act(() =>
|
|
testid('pane-submit')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('inserts a hydrating prompt while the session is active', () => {
|
|
connectionState = { ...connectionState, goalState: undefined };
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
|
|
act(() =>
|
|
testid('pane-submit')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
|
|
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', () => {
|
|
sessionHasActivePromptValue = true;
|
|
render();
|
|
|
|
act(() =>
|
|
testid('pane-submit')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).toHaveBeenCalled();
|
|
expect(queuedPromptStreamingState).toBe('idle');
|
|
expect(queuedPromptSessionHasActivePrompt).toBe(true);
|
|
|
|
sendPrompt.mockClear();
|
|
enqueuePrompt.mockClear();
|
|
act(() => {
|
|
sessionHasActivePromptValue = false;
|
|
rerender();
|
|
});
|
|
act(() =>
|
|
testid('pane-submit')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
|
|
expect(queuedPromptStreamingState).toBe('idle');
|
|
expect(queuedPromptSessionHasActivePrompt).toBe(false);
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends an idle prompt when an active Goal is known', () => {
|
|
connectionState.goalState = {
|
|
v: 2,
|
|
activity: 'idle',
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 1,
|
|
objective: 'ship it',
|
|
status: 'active',
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 1,
|
|
activeTimeMs: 10,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
render();
|
|
|
|
act(() =>
|
|
testid('pane-submit')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
|
|
sendPrompt.mockClear();
|
|
let accepted: boolean | undefined;
|
|
act(() => {
|
|
accepted = latestOnSubmit!('/deploy production');
|
|
});
|
|
expect(accepted).toBe(false);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['idle', 'responding'] as const)(
|
|
'blocks a forwarded slash command while Goal state is hydrating (%s)',
|
|
(streamingState) => {
|
|
streamingStateValue = streamingState;
|
|
connectionState = { ...connectionState, goalState: undefined };
|
|
render();
|
|
|
|
let accepted: boolean | undefined;
|
|
act(() => {
|
|
accepted = latestOnSubmit!('/deploy production');
|
|
});
|
|
|
|
expect(accepted).toBe(false);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('lets the host handle a slash command', () => {
|
|
const onSlashCommand = vi.fn(() => true);
|
|
render({ onSlashCommand });
|
|
let returned: boolean | undefined;
|
|
|
|
act(() => {
|
|
returned = latestOnSubmit!('/deploy production');
|
|
});
|
|
|
|
expect(returned).toBe(true);
|
|
expect(onSlashCommand).toHaveBeenCalledWith({
|
|
command: 'deploy',
|
|
args: 'production',
|
|
input: '/deploy production',
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forwards a slash command the host does not handle', () => {
|
|
const onSlashCommand = vi.fn();
|
|
render({ onSlashCommand });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('/deploy staging');
|
|
});
|
|
|
|
expect(onSlashCommand).toHaveBeenCalledTimes(1);
|
|
expect(sendPrompt).toHaveBeenCalledWith('/deploy staging', {
|
|
submittedPrompt: '/deploy staging',
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('queues a forwarded slash command while the pane is running', () => {
|
|
streamingStateValue = 'responding';
|
|
const onSlashCommand = vi.fn();
|
|
render({ onSlashCommand });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('/deploy staging');
|
|
});
|
|
|
|
expect(onSlashCommand).toHaveBeenCalledTimes(1);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).toHaveBeenCalledWith(
|
|
'/deploy staging',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
expect.any(Function),
|
|
'/deploy staging',
|
|
);
|
|
});
|
|
|
|
it('lets the host handle a slash command while the pane is disconnected', () => {
|
|
connectionState.status = 'disconnected';
|
|
const onSlashCommand = vi.fn(() => true);
|
|
render({ onSlashCommand });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('/deploy staging');
|
|
});
|
|
|
|
expect(onSlashCommand).toHaveBeenCalledTimes(1);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports a host slash command error and continues default handling', () => {
|
|
const error = new Error('host handler exploded');
|
|
const onSlashCommand = vi.fn(() => {
|
|
throw error;
|
|
});
|
|
const onError = vi.fn();
|
|
render({ onSlashCommand, onError });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('/deploy staging');
|
|
});
|
|
|
|
expect(onError).toHaveBeenCalledWith(
|
|
error,
|
|
'onSlashCommand callback failed',
|
|
);
|
|
expect(sendPrompt).toHaveBeenCalledWith('/deploy staging', {
|
|
submittedPrompt: '/deploy staging',
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('does not treat an absolute path as a slash command', () => {
|
|
const onSlashCommand = vi.fn(() => true);
|
|
render({ onSlashCommand });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('/usr/local/bin/tool');
|
|
});
|
|
|
|
expect(onSlashCommand).not.toHaveBeenCalled();
|
|
expect(sendPrompt).toHaveBeenCalledWith('/usr/local/bin/tool', {
|
|
submittedPrompt: '/usr/local/bin/tool',
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('commits an idle prompt only after daemon admission', () => {
|
|
render();
|
|
const commit = vi.fn();
|
|
let returned: boolean | undefined;
|
|
act(() => {
|
|
returned = latestOnSubmit!('hi', undefined, undefined, commit);
|
|
});
|
|
expect(returned).toBe(false);
|
|
expect(commit).not.toHaveBeenCalled();
|
|
act(() => sendPromptAdmit!());
|
|
expect(catalogController.promptAdmitted).toHaveBeenCalledWith(
|
|
'/w',
|
|
'sess-1',
|
|
);
|
|
expect(commit).toHaveBeenCalledTimes(1);
|
|
expect(clearFollowup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not attribute prompt admission across a workspace mismatch', () => {
|
|
connectionState.workspaceCwd = '/other';
|
|
render({ workspaceCwd: '/w' });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('hi');
|
|
sendPromptAdmit!();
|
|
});
|
|
|
|
expect(catalogController.promptAdmitted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not update a catalog without an owning workspace', () => {
|
|
connectionState.workspaceCwd = undefined;
|
|
render();
|
|
|
|
act(() => {
|
|
latestOnSubmit!('hi');
|
|
sendPromptAdmit!();
|
|
});
|
|
|
|
expect(catalogController.promptAdmitted).not.toHaveBeenCalled();
|
|
|
|
streamingStateValue = 'responding';
|
|
rerender();
|
|
act(() => {
|
|
latestOnSubmit!('queued next');
|
|
});
|
|
expect(catalogController.invalidateWorkspace).not.toHaveBeenCalled();
|
|
|
|
streamingStateValue = 'idle';
|
|
rerender();
|
|
expect(catalogController.turnCompleted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forwards images with an idle prompt', () => {
|
|
const images = [{ data: 'image-data', media_type: 'image/png' }];
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('with image', images);
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledWith('with image', {
|
|
submittedPrompt: 'with image',
|
|
images,
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('submits image-only prompts and preserves first-text naming eligibility', () => {
|
|
const images = [{ data: 'image-data', media_type: 'image/png' }];
|
|
const onFirstPromptAdmitted = vi.fn();
|
|
render({ onFirstPromptAdmitted });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('', images);
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledWith('', {
|
|
submittedPrompt: '',
|
|
images,
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
act(() => sendPromptAdmit!());
|
|
expect(onFirstPromptAdmitted).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
latestOnSubmit!('name this task');
|
|
});
|
|
act(() => sendPromptAdmit!());
|
|
expect(onFirstPromptAdmitted).toHaveBeenCalledOnce();
|
|
expect(onFirstPromptAdmitted).toHaveBeenCalledWith('name this task');
|
|
});
|
|
|
|
it('forwards composer annotations with an idle prompt', () => {
|
|
const inputAnnotations = [
|
|
{
|
|
start: 6,
|
|
end: 14,
|
|
text: '@.husky/',
|
|
type: 'file',
|
|
data: { path: '.husky/' },
|
|
},
|
|
];
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('check @.husky/', undefined, undefined, undefined, {
|
|
inputAnnotations,
|
|
});
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledWith('check @.husky/', {
|
|
submittedPrompt: 'check @.husky/',
|
|
inputAnnotations,
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it('queues a prompt while the pane is already running', () => {
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
const commit = vi.fn();
|
|
let returned: boolean | undefined;
|
|
act(() => {
|
|
returned = latestOnSubmit!('queued next', undefined, undefined, commit);
|
|
});
|
|
expect(returned).toBe(true);
|
|
expect(enqueuePrompt).toHaveBeenCalledWith(
|
|
'queued next',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
expect.any(Function),
|
|
'queued next',
|
|
);
|
|
expect(catalogController.invalidateWorkspace).toHaveBeenCalledWith('/w');
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('names a side task when its first text prompt is admitted from the queue', () => {
|
|
streamingStateValue = 'responding';
|
|
const onFirstPromptAdmitted = vi.fn();
|
|
render({ onFirstPromptAdmitted });
|
|
|
|
act(() => {
|
|
latestOnSubmit!('name this queued task');
|
|
});
|
|
|
|
const onAdmitted = enqueuePrompt.mock.calls[0]?.[5] as
|
|
| (() => void)
|
|
| undefined;
|
|
expect(onAdmitted).toEqual(expect.any(Function));
|
|
act(() => onAdmitted?.());
|
|
expect(onFirstPromptAdmitted).toHaveBeenCalledOnce();
|
|
expect(onFirstPromptAdmitted).toHaveBeenCalledWith('name this queued task');
|
|
});
|
|
|
|
it('resynchronizes the owning catalog when a pane turn completes', () => {
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
|
|
streamingStateValue = 'idle';
|
|
rerender();
|
|
|
|
expect(catalogController.turnCompleted).toHaveBeenCalledWith(
|
|
'/w',
|
|
'sess-1',
|
|
);
|
|
});
|
|
|
|
it('does not duplicate turn completion owned by the outer session', () => {
|
|
streamingStateValue = 'responding';
|
|
render({ reportCatalogTurnCompletion: false });
|
|
|
|
streamingStateValue = 'idle';
|
|
rerender({ reportCatalogTurnCompletion: false });
|
|
|
|
expect(catalogController.turnCompleted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not attribute a completed pane turn to a different workspace', () => {
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
|
|
connectionState.workspaceCwd = '/other';
|
|
streamingStateValue = 'idle';
|
|
rerender();
|
|
|
|
expect(catalogController.turnCompleted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('captures a pane identity that becomes available mid-turn', () => {
|
|
connectionState.sessionId = undefined;
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
|
|
connectionState.sessionId = 'sess-late';
|
|
rerender();
|
|
streamingStateValue = 'idle';
|
|
rerender();
|
|
|
|
expect(catalogController.turnCompleted).toHaveBeenCalledWith(
|
|
'/w',
|
|
'sess-late',
|
|
);
|
|
});
|
|
|
|
it('captures a pane workspace that becomes available mid-turn', () => {
|
|
connectionState.workspaceCwd = undefined;
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
|
|
connectionState.workspaceCwd = '/secondary';
|
|
rerender();
|
|
streamingStateValue = 'idle';
|
|
rerender();
|
|
|
|
expect(catalogController.turnCompleted).toHaveBeenCalledTimes(1);
|
|
expect(catalogController.turnCompleted).toHaveBeenCalledWith(
|
|
'/secondary',
|
|
'sess-1',
|
|
);
|
|
expect(catalogController.turnCompleted).not.toHaveBeenCalledWith(
|
|
'/primary',
|
|
'sess-1',
|
|
);
|
|
});
|
|
|
|
it('forwards composer annotations with a queued prompt', () => {
|
|
streamingStateValue = 'responding';
|
|
const inputAnnotations = [
|
|
{
|
|
start: 6,
|
|
end: 14,
|
|
text: '@.husky/',
|
|
type: 'file',
|
|
data: { path: '.husky/' },
|
|
},
|
|
];
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('queue @.husky/', undefined, undefined, undefined, {
|
|
inputAnnotations,
|
|
});
|
|
});
|
|
expect(enqueuePrompt).toHaveBeenCalledWith(
|
|
'queue @.husky/',
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
inputAnnotations,
|
|
expect.any(Function),
|
|
'queue @.husky/',
|
|
);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forwards images with a queued prompt', () => {
|
|
streamingStateValue = 'responding';
|
|
const images = [{ data: 'image-data', media_type: 'image/png' }];
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('queued image', images);
|
|
});
|
|
expect(enqueuePrompt).toHaveBeenCalledWith(
|
|
'queued image',
|
|
images,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
expect.any(Function),
|
|
'queued image',
|
|
);
|
|
});
|
|
|
|
it('queues an image-only prompt while the pane is already running', () => {
|
|
streamingStateValue = 'responding';
|
|
const images = [{ data: 'image-data', media_type: 'image/bmp' }];
|
|
render();
|
|
|
|
act(() => {
|
|
latestOnSubmit!('', images);
|
|
});
|
|
|
|
expect(enqueuePrompt).toHaveBeenCalledWith(
|
|
'',
|
|
images,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
'',
|
|
);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('submits while disconnected when a session exists', () => {
|
|
connectionState.status = 'disconnected';
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('hi');
|
|
});
|
|
|
|
expect(sendPrompt).toHaveBeenCalledWith(
|
|
'hi',
|
|
expect.objectContaining({ onAdmitted: expect.any(Function) }),
|
|
);
|
|
});
|
|
|
|
it('does not submit without a session while disconnected', () => {
|
|
connectionState.status = 'disconnected';
|
|
connectionState.sessionId = undefined;
|
|
render();
|
|
|
|
act(() => {
|
|
latestOnSubmit!('hi');
|
|
});
|
|
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('locks the pane when idle prompt admission outcome is unknown', async () => {
|
|
const onError = vi.fn();
|
|
const onImageIngestionNotice = vi.fn();
|
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
sendPrompt.mockImplementationOnce(async (_text, options) => {
|
|
options?.onAdmissionStarted?.();
|
|
throw new Error('disconnected');
|
|
});
|
|
connectionState.commands = [
|
|
{ name: 'compress', source: 'builtin-command' },
|
|
];
|
|
const registerContextUsageControls = vi.fn(
|
|
(_controls: ContextUsageControls) => vi.fn(),
|
|
);
|
|
render({ onError, onImageIngestionNotice, registerContextUsageControls });
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ canCompress: true }),
|
|
);
|
|
const commit = vi.fn();
|
|
await act(async () => {
|
|
latestOnSubmit!('hi', undefined, undefined, commit);
|
|
await Promise.resolve();
|
|
});
|
|
expect(commit).not.toHaveBeenCalled();
|
|
expect(clearFollowup).not.toHaveBeenCalled();
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(onImageIngestionNotice).toHaveBeenCalledWith(
|
|
'warning',
|
|
expect.stringContaining('uncertain'),
|
|
);
|
|
const notice = testid('pane-prompt-admission-unknown');
|
|
expect(notice).not.toBeNull();
|
|
expect(latestChatEditorProps.disabled).toBe(true);
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ canCompress: false }),
|
|
);
|
|
await act(async () => {
|
|
await registerContextUsageControls.mock.calls.at(-1)![0].compress();
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
expect(catalogController.promptAdmissionUncertain).toHaveBeenCalledWith(
|
|
'/w',
|
|
);
|
|
expect(catalogController.promptAdmitted).not.toHaveBeenCalled();
|
|
act(() => latestOnSubmit!('do not retry'));
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
notice?.querySelectorAll('button').item(0).click();
|
|
});
|
|
expect(confirm).toHaveBeenCalledOnce();
|
|
expect(latestChatEditorProps.disabled).toBe(true);
|
|
|
|
confirm.mockReturnValue(true);
|
|
act(() => {
|
|
notice?.querySelectorAll('button').item(0).click();
|
|
});
|
|
expect(commit).not.toHaveBeenCalled();
|
|
expect(latestChatEditorProps.disabled).toBe(false);
|
|
expect(registerContextUsageControls).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ canCompress: true }),
|
|
);
|
|
expect(testid('pane-prompt-admission-unknown')).not.toBeNull();
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
confirm.mockRestore();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('keeps an unknown admission locked across an unrelated stream', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
sendPrompt.mockImplementationOnce(async (_text, options) => {
|
|
options?.onAdmissionStarted?.();
|
|
throw new Error('response lost');
|
|
});
|
|
render();
|
|
|
|
await act(async () => {
|
|
latestOnSubmit!('hi');
|
|
await Promise.resolve();
|
|
});
|
|
expect(testid('pane-prompt-admission-unknown')).not.toBeNull();
|
|
|
|
streamingStateValue = 'responding';
|
|
rerender();
|
|
|
|
expect(testid('pane-prompt-admission-unknown')).not.toBeNull();
|
|
expect(latestChatEditorProps.disabled).toBe(true);
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('does not mark a turn error unknown after admission', async () => {
|
|
const onError = vi.fn();
|
|
let rejectTurn!: (error: unknown) => void;
|
|
sendPrompt.mockImplementationOnce((_text, options) => {
|
|
options?.onAdmissionStarted?.();
|
|
sendPromptAdmit = options?.onAdmitted;
|
|
return new Promise((_resolve, reject) => {
|
|
rejectTurn = reject;
|
|
});
|
|
});
|
|
render({ onError });
|
|
const commit = vi.fn();
|
|
|
|
act(() => {
|
|
latestOnSubmit!('hi', undefined, undefined, commit);
|
|
sendPromptAdmit?.();
|
|
});
|
|
await act(async () => {
|
|
rejectTurn(new Error('turn failed'));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(commit).toHaveBeenCalledOnce();
|
|
expect(testid('pane-prompt-admission-unknown')).toBeNull();
|
|
expect(latestChatEditorProps.disabled).toBe(false);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: 'turn failed' }),
|
|
'Failed to send prompt',
|
|
);
|
|
});
|
|
|
|
it('discards an unknown local payload without hiding its marker', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
sendPrompt.mockImplementationOnce(async (_text, options) => {
|
|
options?.onAdmissionStarted?.();
|
|
throw new Error('disconnected');
|
|
});
|
|
render();
|
|
const commit = vi.fn();
|
|
await act(async () => {
|
|
latestOnSubmit!('hi', undefined, undefined, commit);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
act(() => {
|
|
testid('pane-prompt-admission-unknown')
|
|
?.querySelectorAll('button')
|
|
.item(1)
|
|
.click();
|
|
});
|
|
|
|
expect(commit).toHaveBeenCalledOnce();
|
|
expect(latestChatEditorProps.disabled).toBe(false);
|
|
expect(testid('pane-prompt-admission-unknown')).not.toBeNull();
|
|
expect(sendPrompt).toHaveBeenCalledTimes(1);
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('keeps the pane editable after a definite 413 rejection', async () => {
|
|
const onError = vi.fn();
|
|
sendPrompt.mockRejectedValueOnce(
|
|
new DaemonHttpError(413, undefined, 'Too large'),
|
|
);
|
|
render({ onError });
|
|
|
|
await act(async () => {
|
|
latestOnSubmit!('hi');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.any(DaemonHttpError),
|
|
'Failed to send prompt',
|
|
);
|
|
expect(latestChatEditorProps.disabled).toBe(false);
|
|
});
|
|
|
|
it('keeps pane approvals click-only (no global keyboard shortcuts)', () => {
|
|
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
|
|
render();
|
|
expect(testid('tool-approval')?.getAttribute('data-keyboard-active')).toBe(
|
|
'false',
|
|
);
|
|
});
|
|
|
|
it('passes this pane workflow to its exit-plan approval', () => {
|
|
messagesState = [
|
|
{
|
|
id: 'plan-update',
|
|
role: 'tool_group',
|
|
tools: [
|
|
{
|
|
callId: 'todo-call-1',
|
|
toolName: 'todo_write',
|
|
status: 'completed',
|
|
rawOutput: {
|
|
entries: [
|
|
{
|
|
content: 'Prepare',
|
|
status: 'completed',
|
|
_meta: { qwenTodo: { id: 'prepare' } },
|
|
},
|
|
{
|
|
content: 'Ship',
|
|
status: 'pending',
|
|
_meta: {
|
|
qwenTodo: { id: 'ship', blockedBy: ['prepare'] },
|
|
},
|
|
},
|
|
],
|
|
plan: { id: 'plan-1' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'plan-update-newer',
|
|
role: 'tool_group',
|
|
tools: [
|
|
{
|
|
callId: 'todo-call-2',
|
|
toolName: 'todo_write',
|
|
status: 'completed',
|
|
rawOutput: {
|
|
entries: [
|
|
{
|
|
content: 'Ship v2',
|
|
status: 'pending',
|
|
_meta: { qwenTodo: { id: 'ship-v2' } },
|
|
},
|
|
],
|
|
plan: { id: 'plan-1' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
messagesState.push({
|
|
id: 'revision',
|
|
role: 'user',
|
|
content: 'Revise the wording',
|
|
});
|
|
pendingPermission = {
|
|
id: 'perm-plan',
|
|
toolKind: 'switch_mode',
|
|
toolName: 'exit_plan_mode',
|
|
todoPlan: { planId: 'plan-1', sourceCallId: 'todo-call-1' },
|
|
rawInput: {},
|
|
};
|
|
|
|
render({ sessionWorkflowEnabled: true });
|
|
|
|
expect(testid('tool-approval')?.getAttribute('data-plan-todos')).toBe(
|
|
'["prepare","ship"]',
|
|
);
|
|
});
|
|
|
|
it('keeps the exit-plan approval text-only when Session Workflow is off', () => {
|
|
messagesState = [
|
|
{
|
|
id: 'plan-update',
|
|
role: 'tool_group',
|
|
tools: [
|
|
{
|
|
callId: 'todo-call-1',
|
|
toolName: 'todo_write',
|
|
status: 'completed',
|
|
rawOutput: {
|
|
entries: [{ content: 'Ship', status: 'pending' }],
|
|
plan: { id: 'plan-1' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
pendingPermission = {
|
|
id: 'perm-plan',
|
|
toolKind: 'switch_mode',
|
|
toolName: 'exit_plan_mode',
|
|
todoPlan: { planId: 'plan-1', sourceCallId: 'todo-call-1' },
|
|
rawInput: {},
|
|
};
|
|
|
|
render();
|
|
|
|
expect(testid('tool-approval')?.getAttribute('data-plan-todos')).toBe('[]');
|
|
});
|
|
|
|
it('reflects streaming state on the composer', () => {
|
|
streamingStateValue = 'responding';
|
|
render();
|
|
expect(testid('pane-running')?.textContent).toBe('true');
|
|
});
|
|
|
|
it('renders a tool approval and resolves it via submitPermission', () => {
|
|
pendingPermission = {
|
|
id: 'perm-1',
|
|
toolName: 'write_file',
|
|
rawInput: {},
|
|
options: [{ id: 'proceed', kind: 'allow_once' }],
|
|
};
|
|
render();
|
|
expect(testid('tool-approval')).not.toBeNull();
|
|
expect(testid('pane-messages')?.getAttribute('data-approval')).toBe('yes');
|
|
expect(testid('pane-dialogopen')?.textContent).toBe('true');
|
|
act(() =>
|
|
testid('tool-approval')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(submitPermission).toHaveBeenCalledWith(
|
|
'perm-1',
|
|
'proceed',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('routes an AskUserQuestion permission to the AskUserQuestion overlay', () => {
|
|
pendingPermission = {
|
|
id: 'ask-1',
|
|
rawInput: { questions: [{ question: 'pick', options: [] }] },
|
|
};
|
|
render();
|
|
expect(testid('ask-approval')).not.toBeNull();
|
|
// Like the tool-approval path, a pane's question must not auto-grab focus —
|
|
// several panes can show at once and stealing focus would yank it from the
|
|
// pane the user is in.
|
|
expect(testid('ask-approval')?.getAttribute('data-keyboard-active')).toBe(
|
|
'false',
|
|
);
|
|
// AskUserQuestion is not a tool approval, so MessageList gets no inline one.
|
|
expect(testid('pane-messages')?.getAttribute('data-approval')).toBe('no');
|
|
});
|
|
|
|
it('invokes onClose from the header close button', () => {
|
|
const onClose = vi.fn();
|
|
render({ onClose });
|
|
const closeBtn = container!.querySelector('[data-testid="pane-close"]');
|
|
act(() =>
|
|
closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('passes this pane owner mode and allows changing it while a turn is active', async () => {
|
|
connectionState.workflowMode = {
|
|
requested: true,
|
|
available: false,
|
|
effective: false,
|
|
};
|
|
connectionState.workflowModeStatus = 'ready';
|
|
streamingStateValue = 'responding';
|
|
sessionHasActivePromptValue = true;
|
|
render();
|
|
expect(latestChatEditorProps.workflowMode).toBe(
|
|
connectionState.workflowMode,
|
|
);
|
|
expect(latestChatEditorProps.workflowModeStatus).toBe('ready');
|
|
expect(latestChatEditorProps.visibleToolbarActions).toContain(
|
|
'workflowMode',
|
|
);
|
|
await act(async () => latestChatEditorProps.onWorkflowModeChange(false));
|
|
expect(setWorkflowMode).toHaveBeenCalledExactlyOnceWith(false);
|
|
expect(setReasoningEffort).not.toHaveBeenCalled();
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not publish a failed mode action after the pane owner changes', async () => {
|
|
const failure = deferred<void>();
|
|
setWorkflowMode.mockReturnValueOnce(failure.promise);
|
|
const onError = vi.fn();
|
|
render({ onError });
|
|
act(() => latestChatEditorProps.onWorkflowModeChange(true));
|
|
ownerVersion += 1;
|
|
await act(async () => failure.reject(new Error('Old owner disconnected')));
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'keeps reasoning persistence scoped with standalone=%s',
|
|
async (standalone) => {
|
|
connectionState.sessionContext = standalone
|
|
? { kind: 'standalone' }
|
|
: undefined;
|
|
render();
|
|
await act(async () => {
|
|
await latestChatEditorProps.onSelectReasoningEffort('medium');
|
|
});
|
|
expect(setReasoningEffort).toHaveBeenCalledWith('medium', {
|
|
persist: !standalone,
|
|
});
|
|
},
|
|
);
|
|
|
|
it('renders no maximize toggle without onToggleMaximize', () => {
|
|
render({ onClose: () => {} });
|
|
expect(container!.querySelector('[aria-label="Maximize pane"]')).toBeNull();
|
|
expect(container!.querySelector('[aria-label="Restore pane"]')).toBeNull();
|
|
});
|
|
|
|
it('invokes onToggleMaximize from the header maximize button', () => {
|
|
const onToggleMaximize = vi.fn();
|
|
render({ onToggleMaximize });
|
|
const maximizeBtn = container!.querySelector(
|
|
'[aria-label="Maximize pane"]',
|
|
);
|
|
expect(maximizeBtn).not.toBeNull();
|
|
expect(maximizeBtn!.querySelector('.lucide-expand')).not.toBeNull();
|
|
// A toggle button always exposes its pressed state; not maximized here.
|
|
expect(maximizeBtn!.getAttribute('aria-pressed')).toBe('false');
|
|
act(() =>
|
|
maximizeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(onToggleMaximize).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shows the restore affordance while maximized', () => {
|
|
render({ onToggleMaximize: () => {}, isMaximized: true });
|
|
const restoreBtn = container!.querySelector('[aria-label="Restore pane"]');
|
|
expect(restoreBtn).not.toBeNull();
|
|
expect(restoreBtn!.querySelector('.lucide-shrink')).not.toBeNull();
|
|
expect(restoreBtn!.getAttribute('aria-pressed')).toBe('true');
|
|
// The label flips to "restore" — no stale "maximize" affordance remains.
|
|
expect(container!.querySelector('[aria-label="Maximize pane"]')).toBeNull();
|
|
});
|
|
|
|
it('renders host header actions scoped to the pane session', () => {
|
|
const renderHeaderActions = vi.fn(
|
|
(info: { sessionId: string; workspaceCwd?: string }) => (
|
|
<button type="button" data-testid="host-pane-action">
|
|
{info.sessionId}:{info.workspaceCwd ?? ''}
|
|
</button>
|
|
),
|
|
);
|
|
render({
|
|
title: 'Refactor core',
|
|
workspaceCwd: '/work/api',
|
|
renderHeaderActions,
|
|
});
|
|
expect(renderHeaderActions).toHaveBeenCalledWith({
|
|
sessionId: 'sess-1',
|
|
workspaceCwd: '/work/api',
|
|
sessionActions: daemonActions,
|
|
});
|
|
expect(testid('host-pane-action')?.textContent).toBe('sess-1:/work/api');
|
|
expect(testid('pane-header-actions')).not.toBeNull();
|
|
});
|
|
|
|
it('cancels the active turn via the composer cancel action', () => {
|
|
render();
|
|
act(() =>
|
|
testid('pane-cancel')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not send a whitespace-only prompt', () => {
|
|
render();
|
|
let returned: boolean | undefined;
|
|
act(() => {
|
|
returned = latestOnSubmit!(' ', undefined, undefined, vi.fn());
|
|
});
|
|
expect(returned).toBe(false);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes queued prompts and queue editing controls to the pane editor', () => {
|
|
queuedPromptsMock = [{ id: 1, text: 'queued next' }];
|
|
queuedTextsMock = ['queued next'];
|
|
render();
|
|
expect(testid('pane-queue')?.textContent).toBe('1');
|
|
expect(testid('pane-queued-messages')?.textContent).toBe(
|
|
JSON.stringify(['queued next']),
|
|
);
|
|
expect(latestChatEditorProps.onPopQueuedMessages()).toBe(false);
|
|
expect(latestChatEditorProps.onClearQueuedMessages()).toBe(false);
|
|
expect(editLastQueuedPrompt).toHaveBeenCalledTimes(1);
|
|
expect(clearQueuedPrompts).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('enables mid-turn queue mutations only when advertised', () => {
|
|
queuedPromptsMock = [{ id: 1, text: 'queued next' }];
|
|
connectionState.capabilities = {
|
|
features: ['session_mid_turn_message_mutation'],
|
|
};
|
|
render();
|
|
|
|
expect(testid('pane-queue')?.dataset.canMutateMidTurn).toBe('true');
|
|
});
|
|
|
|
it('disables mid-turn queue mutations when not advertised', () => {
|
|
queuedPromptsMock = [{ id: 1, text: 'queued next' }];
|
|
render();
|
|
expect(testid('pane-queue')?.dataset.canMutateMidTurn).toBe('false');
|
|
});
|
|
|
|
it('offers send-now on queued rows only when advertised', () => {
|
|
queuedPromptsMock = [{ id: 1, text: 'queued next' }];
|
|
render();
|
|
expect(testid('pane-queue')?.dataset.canSendMidTurnNow).toBe('false');
|
|
connectionState.capabilities = {
|
|
features: ['session_mid_turn_send_now'],
|
|
};
|
|
rerender();
|
|
expect(testid('pane-queue')?.dataset.canSendMidTurnNow).toBe('true');
|
|
act(() => testid('pane-queue')?.click());
|
|
expect(sendQueuedPromptNow).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('passes follow-up suggestions to the pane editor', () => {
|
|
render();
|
|
expect(testid('pane-followup')?.textContent).toBe('next idea');
|
|
expect(latestChatEditorProps.onAcceptFollowup).toBeTypeOf('function');
|
|
expect(latestChatEditorProps.onDismissFollowup).toBeTypeOf('function');
|
|
expect(latestFollowupAccept).toBeTypeOf('function');
|
|
act(() => latestFollowupAccept!('test suggestion'));
|
|
expect(insertText).toHaveBeenCalledWith('test suggestion');
|
|
});
|
|
|
|
it('surfaces a connection-loss banner when the pane connection drops', () => {
|
|
connectionState.error = 'socket closed';
|
|
render();
|
|
expect(container!.textContent).toContain('Connection lost');
|
|
expect(container!.textContent).toContain('socket closed');
|
|
});
|
|
|
|
it('shows no connection banner when the connection is healthy', () => {
|
|
render();
|
|
expect(container!.textContent).not.toContain('Connection lost');
|
|
});
|
|
|
|
it('anchors the streaming timer to the active turn (last user message time)', () => {
|
|
streamingStateValue = 'responding';
|
|
messagesState = [
|
|
{ id: 'u1', role: 'user', content: 'first', timestamp: 1000 },
|
|
{ id: 'a1', role: 'assistant', content: '…', timestamp: 1500 },
|
|
{ id: 'u2', role: 'user', content: 'second', timestamp: 2000 },
|
|
];
|
|
render();
|
|
// The most recent user turn (2000), not "now" or an earlier one.
|
|
expect(testid('pane-streaming')?.getAttribute('data-started-at')).toBe(
|
|
'2000',
|
|
);
|
|
});
|
|
|
|
it('passes no explicit start time while idle', () => {
|
|
streamingStateValue = 'idle';
|
|
render();
|
|
expect(testid('pane-streaming')?.getAttribute('data-started-at')).toBe(
|
|
'none',
|
|
);
|
|
});
|
|
|
|
it('uses this local send clock while the server user echo has not arrived', () => {
|
|
streamingStateValue = 'waiting';
|
|
messagesState = [
|
|
{ id: 'old-user', role: 'user', content: 'old', timestamp: 1000 },
|
|
];
|
|
Object.assign(connectionState, {
|
|
localPrompt: { sessionId: connectionState.sessionId, startedAt: 9000 },
|
|
});
|
|
render();
|
|
expect(testid('pane-streaming')?.getAttribute('data-started-at')).toBe(
|
|
'9000',
|
|
);
|
|
});
|
|
|
|
it('enables the interactive composer controls', () => {
|
|
connectionState.tokenCount = 1200;
|
|
connectionState.contextWindow = 8192;
|
|
render();
|
|
expect(testid('pane-toolbar')?.textContent).toBe(
|
|
JSON.stringify([
|
|
'addMenu',
|
|
'approvalMode',
|
|
'workflowMode',
|
|
'contextUsage',
|
|
'model',
|
|
'voice',
|
|
]),
|
|
);
|
|
expect(latestChatEditorProps.tokenCount).toBe(1200);
|
|
expect(latestChatEditorProps.contextWindow).toBe(8192);
|
|
expect(latestChatEditorProps.onShowContextUsage).toEqual(
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('suppresses stale context and its action after a pane session becomes unavailable', () => {
|
|
connectionState.status = 'disconnected';
|
|
connectionState.sessionId = null;
|
|
connectionState.tokenCount = 23_000;
|
|
connectionState.contextWindow = 131_072;
|
|
render();
|
|
expect(latestChatEditorProps.tokenCount).toBe(0);
|
|
expect(latestChatEditorProps.contextWindow).toBe(0);
|
|
expect(latestChatEditorProps.onShowContextUsage).toBeUndefined();
|
|
});
|
|
|
|
it('suppresses stale context while the pane connection is in error', () => {
|
|
connectionState.status = 'error';
|
|
connectionState.sessionId = 'sess-1';
|
|
connectionState.tokenCount = 23_000;
|
|
connectionState.contextWindow = 131_072;
|
|
render();
|
|
expect(latestChatEditorProps.tokenCount).toBe(0);
|
|
expect(latestChatEditorProps.contextWindow).toBe(0);
|
|
expect(latestChatEditorProps.onShowContextUsage).toBeUndefined();
|
|
});
|
|
|
|
it('opens composer details with the pane session and actions without adding a snapshot', () => {
|
|
const onOpenContextUsage = vi.fn();
|
|
render({ onOpenContextUsage });
|
|
act(() => latestChatEditorProps.onOpenContextUsage());
|
|
expect(onOpenContextUsage).toHaveBeenCalledExactlyOnceWith(
|
|
connectionState.sessionId,
|
|
daemonActions,
|
|
);
|
|
expect(appendLocalUserMessage).not.toHaveBeenCalled();
|
|
expect(getContextUsage).not.toHaveBeenCalled();
|
|
const opener = latestChatEditorProps.onOpenContextUsage;
|
|
rerender({ onOpenContextUsage });
|
|
expect(latestChatEditorProps.onOpenContextUsage).toBe(opener);
|
|
connectionState.status = 'error';
|
|
rerender({ onOpenContextUsage });
|
|
expect(latestChatEditorProps.onOpenContextUsage).toBeUndefined();
|
|
expect(latestChatEditorProps.contextUsageControls.canCompress).toBe(false);
|
|
});
|
|
|
|
it('keeps embedded side-task context read-only without a detail recovery path', () => {
|
|
connectionState.commands = [
|
|
{ name: 'compress', source: 'builtin-command' },
|
|
];
|
|
render({ embedded: true });
|
|
expect(latestChatEditorProps.onShowContextUsage).toBeTypeOf('function');
|
|
expect(latestChatEditorProps.onOpenContextUsage).toBeUndefined();
|
|
expect(latestChatEditorProps.contextUsageControls).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the local context group unavailable for deletion until its result settles', async () => {
|
|
let resolve!: (value: unknown) => void;
|
|
getContextUsage.mockReturnValueOnce(
|
|
new Promise((done) => {
|
|
resolve = done;
|
|
}),
|
|
);
|
|
const begin = vi.spyOn(messageDeletion, 'beginLocalCommand');
|
|
render();
|
|
act(() => latestChatEditorProps.onShowContextUsage());
|
|
expect(begin).toHaveBeenCalledTimes(1);
|
|
const [store] = begin.mock.calls[0]!;
|
|
expect(messageDeletion.hasPendingLocalCommands(store)).toBe(true);
|
|
transcriptDispatch.mockImplementationOnce(() => {
|
|
expect(messageDeletion.hasPendingLocalCommands(store)).toBe(false);
|
|
});
|
|
await act(async () => {
|
|
resolve({ usage: {} });
|
|
});
|
|
expect(messageDeletion.hasPendingLocalCommands(store)).toBe(false);
|
|
begin.mockRestore();
|
|
});
|
|
|
|
it.each(['idle', 'responding'])(
|
|
'shows context usage for this pane session while %s',
|
|
async (state) => {
|
|
streamingStateValue = state;
|
|
render();
|
|
|
|
await act(async () => {
|
|
latestChatEditorProps.onShowContextUsage();
|
|
});
|
|
|
|
if (state === 'idle')
|
|
expect(appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'/context',
|
|
undefined,
|
|
{ localCommandId: expect.stringMatching(/^local-command:/) },
|
|
);
|
|
else expect(appendLocalUserMessage).not.toHaveBeenCalled();
|
|
expect(getContextUsage).toHaveBeenCalledWith({ detail: false });
|
|
expect(transcriptDispatch).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
type: 'status',
|
|
clearActiveText: false,
|
|
text: 'Context Usage',
|
|
data: {
|
|
type: 'web-shell:context-usage:v1:',
|
|
status: { usage: { totalTokens: 1200, contextWindowSize: 8192 } },
|
|
},
|
|
}),
|
|
]);
|
|
},
|
|
);
|
|
|
|
it('hides model setup dynamically while preserving model and session commands', () => {
|
|
connectionState.commands = [
|
|
{ name: 'auth', description: 'Configure models' },
|
|
{ name: 'model', description: 'Select model' },
|
|
{ name: 'delete', description: 'Delete session' },
|
|
];
|
|
render();
|
|
const names = () =>
|
|
latestChatEditorProps.commands.map(
|
|
(command: { name: string }) => command.name,
|
|
);
|
|
expect(names()).toContain('auth');
|
|
rerender({ modelManagement: { allowAdd: false } });
|
|
expect(names()).not.toContain('auth');
|
|
expect(names()).toContain('model');
|
|
expect(names()).toContain('delete');
|
|
rerender({ modelManagement: { allowDelete: false } });
|
|
expect(names()).toContain('auth');
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'refuses model setup only after the host handler declines (busy=%s)',
|
|
(busy) => {
|
|
sessionHasActivePromptValue = busy;
|
|
const onSlashCommand = vi.fn(() => false);
|
|
const onImageIngestionNotice = vi.fn();
|
|
render({
|
|
modelManagement: { allowAdd: false },
|
|
onSlashCommand,
|
|
onImageIngestionNotice,
|
|
});
|
|
let accepted;
|
|
act(() => {
|
|
accepted = latestOnSubmit!('/auth');
|
|
});
|
|
expect(accepted).toBe(true);
|
|
expect(onSlashCommand).toHaveBeenCalledWith({
|
|
command: 'auth',
|
|
args: '',
|
|
input: '/auth',
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
expect(onImageIngestionNotice).toHaveBeenCalledWith(
|
|
'warning',
|
|
'Adding models is disabled by the host.',
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
'lets the host take over a disabled model setup command (busy=%s)',
|
|
(busy) => {
|
|
sessionHasActivePromptValue = busy;
|
|
const onSlashCommand = vi.fn(() => true);
|
|
const onImageIngestionNotice = vi.fn();
|
|
render({
|
|
modelManagement: { allowAdd: false },
|
|
onSlashCommand,
|
|
onImageIngestionNotice,
|
|
});
|
|
let accepted;
|
|
act(() => {
|
|
accepted = latestOnSubmit!('/auth');
|
|
});
|
|
expect(accepted).toBe(true);
|
|
expect(onSlashCommand).toHaveBeenCalledWith({
|
|
command: 'auth',
|
|
args: '',
|
|
input: '/auth',
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
expect(onImageIngestionNotice).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('uses current model management policy in a retained submit callback', () => {
|
|
const onSlashCommand = vi.fn(() => false);
|
|
render({ onSlashCommand });
|
|
const retainedSubmit = latestOnSubmit!;
|
|
const retainedDispatchPolicy = queuedPromptDispatchError!;
|
|
expect(retainedDispatchPolicy('/auth')).toBeUndefined();
|
|
rerender({ onSlashCommand, modelManagement: { allowAdd: false } });
|
|
expect(retainedDispatchPolicy('/auth')).toBe(
|
|
'Adding models is disabled by the host.',
|
|
);
|
|
expect(retainedDispatchPolicy('/model')).toBeUndefined();
|
|
act(() => {
|
|
expect(retainedSubmit('/auth')).toBe(true);
|
|
});
|
|
// The host saw the command first and declined; the policy refused it.
|
|
expect(onSlashCommand).toHaveBeenCalledWith({
|
|
command: 'auth',
|
|
args: '',
|
|
input: '/auth',
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(enqueuePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists the pane session's own commands in the slash menu", () => {
|
|
connectionState.commands = [
|
|
{ name: 'clear', description: 'Clear', source: 'builtin-command' },
|
|
{ name: 'compress', description: 'Compress', source: 'builtin-command' },
|
|
];
|
|
render();
|
|
// Local commands are merged with daemon commands; 'clear' is deduplicated,
|
|
// 'compress' is daemon-only — so the count is localCount + 1.
|
|
const count = Number(testid('pane-commands')?.textContent);
|
|
expect(count).toBeGreaterThan(30);
|
|
});
|
|
|
|
it("passes the pane session's skills to the add menu", () => {
|
|
connectionState.skills = ['review'];
|
|
connectionState.commands = [
|
|
{
|
|
name: 'review',
|
|
description: 'Review code',
|
|
argumentHint: '[path]',
|
|
source: 'skill',
|
|
},
|
|
];
|
|
render();
|
|
|
|
expect(latestChatEditorProps.skills).toEqual([
|
|
{
|
|
name: 'review',
|
|
description: 'Review changed code for bugs, security, and quality',
|
|
argumentHint: '[path]',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('hides internal composer models and labels the rest', () => {
|
|
connectionState.models = [
|
|
{ id: 'coder-model(qwen-oauth)', label: 'Coder' },
|
|
{ id: 'qwen-max', label: 'qwen-max' },
|
|
];
|
|
render();
|
|
const models = JSON.parse(testid('pane-models')!.textContent!);
|
|
expect(models.map((m: { id: string }) => m.id)).toEqual(['qwen-max']);
|
|
});
|
|
|
|
it.each(['default', 'auto-edit', 'auto', 'yolo'])(
|
|
'keeps %s selected while Plan is active and restores it on completion',
|
|
(mode) => {
|
|
connectionState.currentMode = 'plan';
|
|
connectionState.planExecutionMode = mode;
|
|
render({ planControlVisible: true });
|
|
expect(latestChatEditorProps.currentMode).toBe(mode);
|
|
expect(latestChatEditorProps.planMode).toBe(true);
|
|
expect(latestChatEditorProps.visibleToolbarActions).toContain('plan');
|
|
connectionState.currentMode = mode;
|
|
rerender();
|
|
expect(latestChatEditorProps.currentMode).toBe(mode);
|
|
expect(latestChatEditorProps.planMode).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('blocks same-tick plan handoff during a mode request and permits approval after it settles', async () => {
|
|
connectionState.currentMode = 'plan';
|
|
pendingPermission = {
|
|
id: 'plan-approval',
|
|
toolName: 'exit_plan_mode',
|
|
toolKind: 'switch_mode',
|
|
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
|
content: [],
|
|
};
|
|
const mode = deferred<{ mode: string }>();
|
|
setApprovalMode.mockReturnValueOnce(mode.promise);
|
|
render();
|
|
await act(async () => {
|
|
latestChatEditorProps.onSelectMode('yolo');
|
|
await expect(
|
|
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
|
).rejects.toThrow('still pending');
|
|
});
|
|
expect(submitPermission).not.toHaveBeenCalled();
|
|
expect(latestToolApprovalProps!.disabled).toBe(true);
|
|
await act(async () => {
|
|
mode.resolve({ mode: 'plan' });
|
|
});
|
|
expect(latestToolApprovalProps!.disabled).toBe(false);
|
|
await act(async () =>
|
|
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
|
);
|
|
expect(submitPermission).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('holds plan handoff until authoritative mode exit, blocking permission and Plan changes', async () => {
|
|
connectionState.currentMode = 'plan';
|
|
connectionState.planExecutionMode = 'yolo';
|
|
pendingPermission = {
|
|
id: 'plan-approval',
|
|
toolName: 'exit_plan_mode',
|
|
toolKind: 'switch_mode',
|
|
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
|
content: [],
|
|
};
|
|
const permission = deferred<boolean>();
|
|
respondToPermission.mockReturnValueOnce(permission.promise);
|
|
render();
|
|
let submitted: void | Promise<void>;
|
|
act(() => {
|
|
submitted = latestToolApprovalProps!.onConfirm(
|
|
'plan-approval',
|
|
'restore_previous',
|
|
);
|
|
latestChatEditorProps.onSelectMode('default');
|
|
latestChatEditorProps.onTogglePlan();
|
|
});
|
|
expect(respondToPermission).toHaveBeenCalledWith('plan-approval', {
|
|
outcome: { outcome: 'selected', optionId: 'restore_previous' },
|
|
expectedPlanExecutionMode: 'yolo',
|
|
});
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
permission.resolve(true);
|
|
await submitted;
|
|
});
|
|
pendingPermission = null;
|
|
rerender();
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
|
pendingPermission = {
|
|
id: 'background-approval',
|
|
toolName: 'write_file',
|
|
toolKind: 'edit',
|
|
options: [],
|
|
};
|
|
rerender();
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
|
connectionState.currentMode = 'yolo';
|
|
rerender();
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
|
await act(async () => latestChatEditorProps.onTogglePlan());
|
|
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: true });
|
|
});
|
|
|
|
it('releases plan handoff on failure and owner replacement', async () => {
|
|
connectionState.currentMode = 'plan';
|
|
pendingPermission = {
|
|
id: 'plan-approval',
|
|
toolName: 'exit_plan_mode',
|
|
toolKind: 'switch_mode',
|
|
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
|
content: [],
|
|
};
|
|
render();
|
|
submitPermission.mockRejectedValueOnce(new Error('permission failed'));
|
|
await act(async () => {
|
|
await expect(
|
|
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
|
).rejects.toThrow('permission failed');
|
|
});
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
|
await act(async () =>
|
|
latestToolApprovalProps!.onConfirm('plan-approval', 'restore_previous'),
|
|
);
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
|
ownerVersion += 1;
|
|
rerender();
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
|
setApprovalMode.mockRejectedValueOnce(new Error('mode failed'));
|
|
await act(async () => latestChatEditorProps.onTogglePlan());
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(false);
|
|
});
|
|
|
|
it('changes execution permission during Plan without approving the pending plan', async () => {
|
|
connectionState.currentMode = 'plan';
|
|
connectionState.planExecutionMode = 'default';
|
|
pendingPermission = {
|
|
id: 'plan-approval',
|
|
toolName: 'exit_plan_mode',
|
|
toolKind: 'switch_mode',
|
|
options: [{ id: 'restore_previous', kind: 'allow_once' }],
|
|
content: [],
|
|
};
|
|
render();
|
|
await act(async () => latestChatEditorProps.onSelectMode('yolo'));
|
|
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: true });
|
|
expect(submitPermission).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['/plan', '/plan on', '/plan off', '/plan exit'])(
|
|
'retains %s during a pending mode request and accepts it after completion',
|
|
async (command) => {
|
|
connectionState.currentMode = 'yolo';
|
|
const pendingMode = deferred<{ mode: string }>();
|
|
setApprovalMode.mockReturnValueOnce(pendingMode.promise);
|
|
const onError = vi.fn();
|
|
render({ onError });
|
|
act(() => {
|
|
latestChatEditorProps.onSelectMode('yolo');
|
|
});
|
|
expect(latestChatEditorProps.modeControlsDisabled).toBe(true);
|
|
|
|
let accepted: boolean | undefined;
|
|
act(() => {
|
|
accepted = latestOnSubmit!(command);
|
|
});
|
|
expect(accepted).toBe(false);
|
|
expect(setApprovalMode).toHaveBeenCalledTimes(1);
|
|
expect(onError).toHaveBeenCalled();
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
pendingMode.resolve({ mode: 'yolo' });
|
|
});
|
|
await act(async () => {
|
|
accepted = latestOnSubmit!(command);
|
|
});
|
|
expect(accepted).toBe(true);
|
|
expect(setApprovalMode).toHaveBeenCalledTimes(2);
|
|
expect(setApprovalMode).toHaveBeenLastCalledWith('yolo', {
|
|
planMode: command === '/plan' || command === '/plan on',
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(['/plan', '/plan on', '/plan off'])(
|
|
'does not consume %s while disconnected without a session',
|
|
(command) => {
|
|
connectionState.status = 'disconnected';
|
|
connectionState.sessionId = undefined;
|
|
render();
|
|
let accepted: boolean | undefined;
|
|
act(() => {
|
|
accepted = latestOnSubmit!(command);
|
|
});
|
|
expect(accepted).toBe(false);
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['success', 'failure', 'replacement'])(
|
|
'blocks compression while preparing a /plan prompt and releases the gate after %s',
|
|
async (outcome) => {
|
|
connectionState.commands = [
|
|
{ name: 'compress', source: 'builtin-command' },
|
|
];
|
|
const prepared = deferred<{ mode: string }>();
|
|
setApprovalMode.mockReturnValueOnce(prepared.promise);
|
|
let controls!: ContextUsageControls;
|
|
const registerContextUsageControls = (value: ContextUsageControls) => {
|
|
controls = value;
|
|
return () => {};
|
|
};
|
|
render({ registerContextUsageControls });
|
|
expect(controls.canCompress).toBe(true);
|
|
const staleCompress = controls.compress;
|
|
act(() => {
|
|
latestOnSubmit!('/plan explain the migration');
|
|
});
|
|
expect(setApprovalMode).toHaveBeenCalledOnce();
|
|
expect(controls.canCompress).toBe(false);
|
|
await act(async () => staleCompress());
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
if (outcome === 'replacement') {
|
|
ownerVersion++;
|
|
connectionState.sessionId = 'replacement';
|
|
rerender({ registerContextUsageControls });
|
|
expect(controls.canCompress).toBe(true);
|
|
}
|
|
await act(async () => {
|
|
if (outcome === 'failure') prepared.reject(new Error('mode failed'));
|
|
else prepared.resolve({ mode: 'plan' });
|
|
});
|
|
expect(controls.canCompress).toBe(true);
|
|
if (outcome === 'success') {
|
|
expect(sendPrompt).toHaveBeenCalledExactlyOnceWith(
|
|
'explain the migration',
|
|
expect.any(Object),
|
|
);
|
|
} else expect(sendPrompt).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('sends /plan prompts through normal admission callbacks after applying Plan', async () => {
|
|
const firstPrompt = vi.fn();
|
|
const commit = vi.fn();
|
|
connectionState.currentMode = 'yolo';
|
|
render({ onFirstPromptAdmitted: firstPrompt });
|
|
await act(async () => {
|
|
latestOnSubmit!(
|
|
'/plan explain the migration',
|
|
undefined,
|
|
undefined,
|
|
commit,
|
|
);
|
|
});
|
|
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: true });
|
|
expect(sendPrompt).toHaveBeenCalledWith(
|
|
'explain the migration',
|
|
expect.objectContaining({
|
|
onAdmissionStarted: expect.any(Function),
|
|
onAdmitted: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(commit).not.toHaveBeenCalled();
|
|
act(() => sendPromptAdmit!());
|
|
expect(commit).toHaveBeenCalledOnce();
|
|
expect(firstPrompt).toHaveBeenCalledWith('explain the migration');
|
|
expect(catalogController.promptAdmitted).toHaveBeenCalledWith(
|
|
'/w',
|
|
'sess-1',
|
|
);
|
|
});
|
|
|
|
it('locks uncertain /plan prompt admission instead of permitting a duplicate', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
sendPrompt.mockImplementationOnce(async (_text, options) => {
|
|
options?.onAdmissionStarted?.();
|
|
throw new Error('disconnected');
|
|
});
|
|
render();
|
|
await act(async () => {
|
|
latestOnSubmit!('/plan explain the migration');
|
|
});
|
|
expect(testid('pane-prompt-admission-unknown')).not.toBeNull();
|
|
expect(latestChatEditorProps.disabled).toBe(true);
|
|
act(() => {
|
|
latestOnSubmit!('/plan duplicate');
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledOnce();
|
|
expect(catalogController.promptAdmissionUncertain).toHaveBeenCalledWith(
|
|
'/w',
|
|
);
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('does not send a prepared /plan prompt into a replacement owner', async () => {
|
|
const prepared = deferred<{ mode: string }>();
|
|
setApprovalMode.mockReturnValueOnce(prepared.promise);
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('/plan explain');
|
|
});
|
|
ownerVersion += 1;
|
|
connectionState.sessionId = 'replacement';
|
|
rerender();
|
|
await act(async () => {
|
|
prepared.resolve({ mode: 'plan' });
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drives THIS pane's approval mode when one is picked", () => {
|
|
render();
|
|
act(() =>
|
|
testid('pane-pick-mode')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: false });
|
|
});
|
|
|
|
it("switches THIS pane's model when one is picked", () => {
|
|
render();
|
|
act(() =>
|
|
testid('pane-pick-model')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(setModel).toHaveBeenCalledWith('gpt-x');
|
|
});
|
|
|
|
it("reflects the pane session's current mode and model", () => {
|
|
connectionState.currentMode = 'auto-edit';
|
|
connectionState.currentModel = 'qwen-max';
|
|
render();
|
|
expect(testid('pane-mode')?.textContent).toBe('auto-edit');
|
|
expect(testid('pane-model')?.textContent).toBe('qwen-max');
|
|
});
|
|
|
|
it('falls back to the default mode and empty model when unset', () => {
|
|
render();
|
|
expect(testid('pane-mode')?.textContent).toBe('default');
|
|
expect(testid('pane-model')?.textContent).toBe('');
|
|
});
|
|
|
|
it('reports a failed model switch to onError', async () => {
|
|
const onError = vi.fn();
|
|
setModel.mockRejectedValueOnce(new Error('switch failed'));
|
|
render({ onError });
|
|
await act(async () => {
|
|
testid('pane-pick-model')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports a failed approval mode switch to onError', async () => {
|
|
const onError = vi.fn();
|
|
setApprovalMode.mockRejectedValueOnce(new Error('mode switch failed'));
|
|
render({ onError });
|
|
await act(async () => {
|
|
testid('pane-pick-mode')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an invalid approval mode without calling the daemon', () => {
|
|
const onError = vi.fn();
|
|
render({ onError });
|
|
act(() =>
|
|
testid('pane-pick-badmode')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('auto-approves a pending tool call when the pane switches to yolo', async () => {
|
|
pendingPermission = {
|
|
id: 'perm-yolo',
|
|
toolName: 'write_file',
|
|
toolKind: 'edit',
|
|
options: [{ id: 'allow-1', label: 'Allow once', kind: 'allow_once' }],
|
|
rawInput: {},
|
|
};
|
|
render();
|
|
await act(async () => {
|
|
testid('pane-pick-mode')!.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true }),
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(setApprovalMode).toHaveBeenCalledWith('yolo', { planMode: false });
|
|
expect(submitPermission).toHaveBeenCalledWith('perm-yolo', 'allow-1');
|
|
});
|
|
|
|
it('resolves host at mention providers through a pane ChatEditor', () => {
|
|
renderRealChatEditor = true;
|
|
const atProviders = [
|
|
{
|
|
id: 'tables',
|
|
label: 'Tables',
|
|
async search() {
|
|
return [];
|
|
},
|
|
},
|
|
];
|
|
const builtinAtProviders = { exclude: ['extensions'] as const };
|
|
|
|
render({}, { atProviders, builtinAtProviders });
|
|
|
|
expect(latestComposerCoreOptions.current?.atProviders).toBe(atProviders);
|
|
expect(latestComposerCoreOptions.current?.builtinAtProviders).toBe(
|
|
builtinAtProviders,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ChatPane daemon keep-alive (#9487)', () => {
|
|
it('passes the daemon active-prompt flag to the indicator and composer', () => {
|
|
streamingStateValue = 'idle';
|
|
sessionHasActivePromptValue = true;
|
|
render({ onError: vi.fn() });
|
|
|
|
const status = testid('pane-streaming');
|
|
expect(status).not.toBeNull();
|
|
expect(status!.getAttribute('data-has-active-prompt')).toBe('true');
|
|
// The stop/cancel affordance stays available during the silent gap:
|
|
// the daemon still has an active prompt, so cancel genuinely works.
|
|
expect(testid('pane-running')!.textContent).toBe('true');
|
|
});
|
|
|
|
it('keeps the pane idle without a daemon active prompt', () => {
|
|
streamingStateValue = 'idle';
|
|
sessionHasActivePromptValue = false;
|
|
render({ onError: vi.fn() });
|
|
|
|
const status = testid('pane-streaming');
|
|
expect(status!.getAttribute('data-has-active-prompt')).toBe('false');
|
|
expect(testid('pane-running')!.textContent).toBe('false');
|
|
});
|
|
});
|
|
|
|
describe('ChatPane continuation errors', () => {
|
|
it.each([false, true])(
|
|
'keeps a current continuation failure visible and isolates old owners (replace owner: %s)',
|
|
async (replaceOwner) => {
|
|
const { createDaemonSessionActions } = await import(
|
|
'../daemon/session/actions'
|
|
);
|
|
const request = deferred<never>();
|
|
const continueRequest = vi.fn(() => request.promise);
|
|
const addNotice = vi.fn();
|
|
const onError = vi.fn();
|
|
const requestError = new TypeError('Failed to fetch continuation');
|
|
const sessionRef = {
|
|
current: {
|
|
sessionId: 'sess-1',
|
|
clientId: 'pane-client',
|
|
continueSession: continueRequest,
|
|
},
|
|
};
|
|
connectionState.context = {
|
|
v: 1,
|
|
sessionId: 'sess-1',
|
|
workspaceCwd: '/w',
|
|
state: {},
|
|
recovery: { kind: 'interrupted_prompt', canContinue: true },
|
|
};
|
|
// Actual action + actual ChatPane/Banner; only SDK request and hook state
|
|
// delivery are simulated. No SSE frames or provider replay are invented.
|
|
const actions = createDaemonSessionActions({
|
|
store: { getSnapshot: () => ({ activeAssistantBlockId: undefined }) },
|
|
sessionRef,
|
|
activePromptsRef: { current: new Map() },
|
|
settledPromptsRef: { current: new Map() },
|
|
pendingSessionLoadIdRef: { current: 0 },
|
|
sessionRecoveryGeneration: new WeakMap(),
|
|
passiveAssistantDoneTimerRef: { current: undefined },
|
|
getConnection: () => connectionState,
|
|
hasSessionActivePrompt: () => false,
|
|
setConnection: (next: unknown) => {
|
|
connectionState =
|
|
typeof next === 'function' ? next(connectionState) : next;
|
|
},
|
|
setPromptStatus: vi.fn(),
|
|
addNotice,
|
|
} as unknown as Parameters<typeof createDaemonSessionActions>[0]);
|
|
const continueAction = vi.fn(actions.continueSession);
|
|
Object.assign(daemonActions, { continueSession: continueAction });
|
|
try {
|
|
render({ onError });
|
|
const button = testid('session-recovery-banner')?.querySelector(
|
|
'button',
|
|
);
|
|
expect(button).toBeTruthy();
|
|
act(() => {
|
|
button!.click();
|
|
button!.click();
|
|
});
|
|
rerender({ onError });
|
|
expect(continueRequest).toHaveBeenCalledOnce();
|
|
expect(connectionState.context.recovery.canContinue).toBe(false);
|
|
expect(
|
|
testid('session-recovery-banner')?.querySelector('button'),
|
|
).toBeFalsy();
|
|
if (replaceOwner) {
|
|
ownerVersion += 1;
|
|
sessionRef.current = { ...sessionRef.current, sessionId: 'sess-2' };
|
|
connectionState = {
|
|
...connectionState,
|
|
sessionId: 'sess-2',
|
|
context: { ...connectionState.context, sessionId: 'sess-2' },
|
|
};
|
|
rerender({ onError });
|
|
}
|
|
await act(async () => {
|
|
request.reject(requestError);
|
|
await Promise.resolve();
|
|
});
|
|
await expect(continueAction.mock.results[0]!.value).rejects.toBe(
|
|
requestError,
|
|
);
|
|
rerender({ onError });
|
|
const inlineErrors = Array.from(
|
|
container!.querySelectorAll('[role="alert"]'),
|
|
)
|
|
.map((alert) => alert.textContent)
|
|
.filter(Boolean);
|
|
expect(continueRequest).toHaveBeenCalledOnce();
|
|
expect(connectionState.context.recovery.canContinue).toBe(false);
|
|
expect(
|
|
testid('session-recovery-banner')?.querySelector('button'),
|
|
).toBeFalsy();
|
|
if (replaceOwner) {
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(inlineErrors).toEqual([]);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(addNotice).toHaveBeenCalledOnce();
|
|
expect(inlineErrors).toEqual([
|
|
'Could not continue the conversation.',
|
|
]);
|
|
}
|
|
} finally {
|
|
Reflect.deleteProperty(daemonActions, 'continueSession');
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it('requires an explicit resume for a stopped pane', async () => {
|
|
connectionState.runtimeStopped = true;
|
|
connectionState.status = 'disconnected';
|
|
connectionState.sessionId = 'stopped';
|
|
connectionState.sessionContext = { kind: 'workspace', cwd: '/workspace' };
|
|
loadSession.mockClear();
|
|
render();
|
|
expect(testid('workspace-runtime-stopped')).not.toBeNull();
|
|
expect(loadSession).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
[...container!.querySelectorAll('button')]
|
|
.find((node) => node.textContent === 'Resume conversation')!
|
|
.click();
|
|
});
|
|
expect(loadSession).toHaveBeenCalledWith('stopped', {
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
});
|
|
});
|
|
|
|
it('fences pane submits while the runtime is stopped and says why', async () => {
|
|
connectionState.runtimeStopped = true;
|
|
connectionState.status = 'disconnected';
|
|
// The parked state retains sessionId, so the status-based submit guard
|
|
// alone does not catch this.
|
|
connectionState.sessionId = 'stopped';
|
|
const notice = vi.fn();
|
|
render({ onImageIngestionNotice: notice });
|
|
await act(async () => {
|
|
testid('pane-submit')!.click();
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(notice).toHaveBeenCalledWith(
|
|
'warning',
|
|
'This workspace was stopped to free ACP capacity. Resume this conversation when needed.',
|
|
);
|
|
});
|
|
|
|
it('does not submit a deferred /plan prompt after the runtime stops', async () => {
|
|
const prepared = deferred<{ mode: string }>();
|
|
setApprovalMode.mockReturnValueOnce(prepared.promise);
|
|
render();
|
|
act(() => {
|
|
latestOnSubmit!('/plan explain the migration');
|
|
});
|
|
expect(setApprovalMode).toHaveBeenCalledOnce();
|
|
// The runtime stops while the plan-mode switch is still in flight; the
|
|
// deferred continuation must re-check the fence before submitting.
|
|
connectionState = {
|
|
...connectionState,
|
|
runtimeStopped: true,
|
|
status: 'disconnected',
|
|
};
|
|
rerender();
|
|
await act(async () => prepared.resolve({ mode: 'plan' }));
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['/auth', '/login', '/connect'])(
|
|
'blocks rewritten /plan %s before changing mode',
|
|
(command) => {
|
|
const notice = vi.fn();
|
|
connectionState.commands = [
|
|
{
|
|
name: 'auth',
|
|
source: 'builtin-command',
|
|
altNames: ['login', 'connect'],
|
|
},
|
|
];
|
|
render({
|
|
modelManagement: { allowAdd: false },
|
|
onImageIngestionNotice: notice,
|
|
});
|
|
act(() => {
|
|
expect(latestOnSubmit!(`/plan ${command}`)).toBe(true);
|
|
});
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(notice).toHaveBeenCalledWith(
|
|
'warning',
|
|
'Adding models is disabled by the host.',
|
|
);
|
|
},
|
|
);
|
|
it('rechecks model policy after asynchronous plan preparation', async () => {
|
|
const prepared = deferred<{ mode: string }>();
|
|
setApprovalMode.mockReturnValueOnce(prepared.promise);
|
|
const notice = vi.fn();
|
|
render({ onImageIngestionNotice: notice });
|
|
act(() => {
|
|
latestOnSubmit!('/plan /auth');
|
|
});
|
|
expect(setApprovalMode).toHaveBeenCalledOnce();
|
|
rerender({
|
|
modelManagement: { allowAdd: false },
|
|
onImageIngestionNotice: notice,
|
|
});
|
|
await act(async () => {
|
|
prepared.resolve({ mode: 'yolo' });
|
|
});
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
expect(notice).toHaveBeenCalledWith(
|
|
'warning',
|
|
'Adding models is disabled by the host.',
|
|
);
|
|
});
|
|
it('preserves a stopped pane draft even when model setup is disabled', () => {
|
|
connectionState.runtimeStopped = true;
|
|
const notice = vi.fn();
|
|
render({
|
|
modelManagement: { allowAdd: false },
|
|
onImageIngestionNotice: notice,
|
|
});
|
|
act(() => {
|
|
expect(latestOnSubmit!('/auth')).toBe(false);
|
|
});
|
|
expect(notice).toHaveBeenCalledExactlyOnceWith(
|
|
'warning',
|
|
'This workspace was stopped to free ACP capacity. Resume this conversation when needed.',
|
|
);
|
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
});
|
|
it.each(['builtin-command', 'project', 'missing'])(
|
|
'uses loaded %s identity for the auth menu and dispatch',
|
|
async (source) => {
|
|
connectionState.commands = [
|
|
{ name: 'clear', source: 'builtin-command' },
|
|
// A ready snapshot without any auth entry (disabled list, SSH
|
|
// whitelist) must fail closed exactly like the builtin identity does.
|
|
...(source === 'missing' ? [] : [{ name: 'auth', source }]),
|
|
];
|
|
render({ modelManagement: { allowAdd: false } });
|
|
const names = latestChatEditorProps.commands.map(
|
|
(command: { name: string }) => command.name,
|
|
);
|
|
expect(names.includes('auth')).toBe(source === 'project');
|
|
await act(async () => {
|
|
latestOnSubmit!('/auth');
|
|
});
|
|
expect(sendPrompt).toHaveBeenCalledTimes(source === 'project' ? 1 : 0);
|
|
},
|
|
);
|
|
|
|
describe('pane approval mode allowlist', () => {
|
|
beforeEach(() => {
|
|
connectionState.capabilities = {
|
|
features: ['workspace_qualified_rest_core', 'workspace_settings'],
|
|
};
|
|
});
|
|
|
|
it('applies host choices to split and embedded side-task composers', async () => {
|
|
connectionState.currentMode = 'yolo';
|
|
render({ embedded: true }, { allowedApprovalModes: ['default', 'auto'] });
|
|
expect(latestChatEditorProps.availableModes).toEqual(['default', 'auto']);
|
|
act(() => latestChatEditorProps.onSelectMode('yolo'));
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
act(() => latestChatEditorProps.onCycleMode());
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(setApprovalMode).toHaveBeenCalledWith('default', {
|
|
planMode: false,
|
|
});
|
|
expect(workspaceByCwd).not.toHaveBeenCalled();
|
|
rerender({ embedded: true }, { allowedApprovalModes: [] });
|
|
setApprovalMode.mockClear();
|
|
act(() => latestChatEditorProps.onCycleMode());
|
|
expect(latestChatEditorProps.availableModes).toEqual([]);
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports when a directly selected mode is not offered', () => {
|
|
const onError = vi.fn();
|
|
render({ onError }, { allowedApprovalModes: ['default', 'auto'] });
|
|
act(() => latestChatEditorProps.onSelectMode('yolo'));
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('not offered here: yolo'),
|
|
}),
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it('keeps reporting plan as unsupported when an allowlist is set', async () => {
|
|
const onError = vi.fn();
|
|
render({ onError }, { allowedApprovalModes: ['auto'] });
|
|
await act(async () => {
|
|
latestChatEditorProps.onSelectMode('plan');
|
|
await Promise.resolve();
|
|
});
|
|
expect(setApprovalMode).not.toHaveBeenCalled();
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining(
|
|
'Unsupported execution approval mode: plan',
|
|
),
|
|
}),
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it('reads its own workspace settings and drops old choices on workspace change', async () => {
|
|
paneSettings.mockResolvedValueOnce({
|
|
v: 1,
|
|
settings: [
|
|
{ key: 'ui.allowedApprovalModes', values: { effective: ['auto'] } },
|
|
],
|
|
} as never);
|
|
render({ workspaceCwd: '/secondary' });
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(workspaceByCwd).toHaveBeenCalledWith('/secondary');
|
|
expect(latestChatEditorProps.availableModes).toEqual(['auto']);
|
|
paneSettings.mockResolvedValueOnce({
|
|
v: 1,
|
|
settings: [
|
|
{ key: 'ui.allowedApprovalModes', values: { effective: ['default'] } },
|
|
],
|
|
} as never);
|
|
rerender({ workspaceCwd: '/third' });
|
|
expect(latestChatEditorProps.availableModes).toEqual([
|
|
'default',
|
|
'auto-edit',
|
|
'auto',
|
|
'yolo',
|
|
]);
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(workspaceByCwd).toHaveBeenLastCalledWith('/third');
|
|
expect(latestChatEditorProps.availableModes).toEqual(['default']);
|
|
});
|
|
|
|
it('ignores an older workspace response after the pane has moved', async () => {
|
|
const old = deferred<Awaited<ReturnType<typeof paneSettings>>>();
|
|
paneSettings.mockReturnValueOnce(old.promise);
|
|
render({ workspaceCwd: '/old' });
|
|
paneSettings.mockResolvedValueOnce({
|
|
v: 1,
|
|
settings: [
|
|
{ key: 'ui.allowedApprovalModes', values: { effective: ['auto'] } },
|
|
],
|
|
} as never);
|
|
rerender({ workspaceCwd: '/new' });
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(latestChatEditorProps.availableModes).toEqual(['auto']);
|
|
await act(async () => {
|
|
old.resolve({
|
|
v: 1,
|
|
settings: [
|
|
{ key: 'ui.allowedApprovalModes', values: { effective: ['yolo'] } },
|
|
],
|
|
} as never);
|
|
await old.promise;
|
|
});
|
|
expect(latestChatEditorProps.availableModes).toEqual(['auto']);
|
|
});
|
|
|
|
it('does not read primary settings for standalone panes or failed qualified reads', async () => {
|
|
connectionState.sessionContext = { kind: 'standalone' };
|
|
render();
|
|
expect(workspaceByCwd).not.toHaveBeenCalled();
|
|
connectionState.sessionContext = undefined;
|
|
paneSettings.mockRejectedValueOnce(new Error('workspace unavailable'));
|
|
rerender({ workspaceCwd: '/missing' });
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(workspaceByCwd).toHaveBeenCalledOnce();
|
|
// A failed scoped read fails closed rather than offering every mode.
|
|
expect(latestChatEditorProps.availableModes).toEqual([]);
|
|
});
|
|
});
|