Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
624 lines
18 KiB
TypeScript
624 lines
18 KiB
TypeScript
import "@testing-library/jest-dom/vitest";
|
|
import { render } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
AgentGUIAgentTarget,
|
|
AgentGUINodeData,
|
|
NodeFrame
|
|
} from "../../types";
|
|
import type { AgentGUIConversationSummary } from "./model/agentGuiConversationModel";
|
|
import type {
|
|
AgentComposerDraft,
|
|
AgentGUINodeViewModel
|
|
} from "./model/agentGuiNodeTypes";
|
|
import {
|
|
groupAgentGUINodeViewModelFixture,
|
|
type AgentGUINodeViewModelFixtureOverrides,
|
|
type FlatAgentGUINodeViewModelFixture
|
|
} from "./model/AgentGUINodeViewModel.fixture";
|
|
import { AgentGUINode } from "./AgentGUINode";
|
|
import { createLocalAgentGUIAgentTarget } from "../../agentTargets";
|
|
import { buildAgentComposerDraft } from "./model/agentComposerDraft";
|
|
import { resolveNextAgentGUIConversationRailWidthPx } from "./model/agentGuiRailLayout";
|
|
|
|
const { agentGuiNodeViewSpy, translate } = vi.hoisted(() => ({
|
|
agentGuiNodeViewSpy: vi.fn(),
|
|
translate: (key: string, options?: Record<string, unknown>) => {
|
|
if (key === "agentHost.workspaceAgentSessionDetailToolCalls") {
|
|
return `${options?.count ?? 0} tool calls`;
|
|
}
|
|
if (key === "agentHost.agentGui.followupPlaceholder") {
|
|
return `Request ${options?.provider ?? ""} to continue`;
|
|
}
|
|
return key;
|
|
}
|
|
}));
|
|
|
|
let mockViewModel: AgentGUINodeViewModel;
|
|
|
|
vi.mock("../../i18n/index", () => ({
|
|
useTranslation: () => ({
|
|
t: translate
|
|
})
|
|
}));
|
|
|
|
vi.mock("./controller/useAgentGUINodeController", () => ({
|
|
useAgentGUINodeController: () => ({
|
|
viewModel: mockViewModel,
|
|
actions: {
|
|
createConversation: vi.fn(),
|
|
selectConversation: vi.fn(),
|
|
submitPrompt: vi.fn(),
|
|
submitGuidancePrompt: vi.fn(),
|
|
showPromptImagesUnsupported: vi.fn(),
|
|
submitApprovalOption: vi.fn(),
|
|
submitInteractivePrompt: vi.fn(),
|
|
interruptCurrentTurn: vi.fn(),
|
|
updateDraftContent: vi.fn(),
|
|
updateComposerSettings: vi.fn(),
|
|
sendQueuedPromptNext: vi.fn(),
|
|
removeQueuedPrompt: vi.fn(),
|
|
editQueuedPrompt: vi.fn(),
|
|
removeProject: vi.fn(),
|
|
toggleProjectPinned: vi.fn(),
|
|
confirmDeleteProjectConversations: vi.fn(),
|
|
requestDeleteConversation: vi.fn(),
|
|
retryActivation: vi.fn(),
|
|
continueInNewConversation: vi.fn(),
|
|
cancelDeleteConversation: vi.fn(),
|
|
confirmDeleteConversation: vi.fn()
|
|
}
|
|
})
|
|
}));
|
|
|
|
vi.mock("./AgentGUINodeView", () => ({
|
|
AgentGUINodeView: (props: unknown) => {
|
|
agentGuiNodeViewSpy(props);
|
|
return <div data-testid="agent-gui-view" />;
|
|
}
|
|
}));
|
|
|
|
vi.mock("../shared/WorkspaceNodeWindow", () => ({
|
|
WorkspaceNodeWindow: ({
|
|
children,
|
|
width,
|
|
height
|
|
}: {
|
|
children: (frame: {
|
|
size: { width: number; height: number };
|
|
}) => React.ReactNode;
|
|
width: number;
|
|
height: number;
|
|
}) => <div>{children({ size: { width, height } })}</div>
|
|
}));
|
|
|
|
vi.mock("../shared/CanvasNodeGhostIconButton", () => ({
|
|
CanvasNodeGhostIconButton: ({ children }: { children: React.ReactNode }) => (
|
|
<button>{children}</button>
|
|
)
|
|
}));
|
|
|
|
vi.mock("../shared/canvasNodeChromeIcons", () => ({
|
|
CanvasNodePanelLinedIcon: () => <span data-testid="panel-icon" />
|
|
}));
|
|
|
|
vi.mock("../workspaceDesktop/view/AgentProbeInfoPopover", () => ({
|
|
AgentProbeInfoPopover: () => <div data-testid="agent-probe-popover" />
|
|
}));
|
|
|
|
describe("AgentGUINode memoization", () => {
|
|
afterEach(() => {
|
|
agentGuiNodeViewSpy.mockReset();
|
|
});
|
|
|
|
it("rerenders when its host status controller changes", () => {
|
|
mockViewModel = createViewModel();
|
|
const firstController = createStatusControllerStub();
|
|
const props = createProps({
|
|
runtimeRequests: {
|
|
agentStatusController: firstController
|
|
}
|
|
});
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
agentGuiNodeViewSpy.mockClear();
|
|
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
runtimeRequests={{
|
|
...props.runtimeRequests,
|
|
agentStatusController: createStatusControllerStub()
|
|
}}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rerenders when Session input history is enabled", () => {
|
|
mockViewModel = createViewModel();
|
|
const props = createProps();
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ sessionInputHistoryEnabled: false })
|
|
);
|
|
agentGuiNodeViewSpy.mockClear();
|
|
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
hostCapabilities={{ sessionInputHistoryEnabled: true }}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ sessionInputHistoryEnabled: true })
|
|
);
|
|
});
|
|
|
|
it("rerenders when Side conversations are enabled", () => {
|
|
mockViewModel = createViewModel();
|
|
const props = createProps();
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ sideConversationEnabled: false })
|
|
);
|
|
agentGuiNodeViewSpy.mockClear();
|
|
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
hostCapabilities={{ sideConversationEnabled: true }}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ sideConversationEnabled: true })
|
|
);
|
|
});
|
|
|
|
it("rerenders when per-target composer overrides change", () => {
|
|
mockViewModel = createViewModel();
|
|
const props = createProps({
|
|
state: createState({
|
|
agentTargetId: "target-a",
|
|
composerOverridesByAgentTargetId: {
|
|
"target-a": { model: "gpt-5" }
|
|
}
|
|
})
|
|
});
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
agentGuiNodeViewSpy.mockClear();
|
|
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
state={createState({
|
|
agentTargetId: "target-a",
|
|
composerOverridesByAgentTargetId: {
|
|
"target-a": { model: "gpt-5.1" }
|
|
}
|
|
})}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rerenders when per-target session memory changes", () => {
|
|
mockViewModel = createViewModel();
|
|
const props = createProps({
|
|
state: createState({
|
|
lastActiveAgentSessionIdByAgentTargetId: {
|
|
"local:codex": "session-1"
|
|
}
|
|
})
|
|
});
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
agentGuiNodeViewSpy.mockClear();
|
|
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
state={createState({
|
|
lastActiveAgentSessionIdByAgentTargetId: {
|
|
"local:codex": "session-2"
|
|
}
|
|
})}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes reference content error recovery through the memo boundary", () => {
|
|
mockViewModel = createViewModel();
|
|
const firstResolver = vi.fn();
|
|
const secondResolver = vi.fn();
|
|
const initial = createProps();
|
|
const props = {
|
|
...initial,
|
|
workspace: {
|
|
...initial.workspace,
|
|
resolveReferenceContentErrorAction: firstResolver
|
|
}
|
|
};
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(
|
|
agentGuiNodeViewSpy.mock.calls.at(-1)?.[0]
|
|
.resolveReferenceContentErrorAction
|
|
).toBe(firstResolver);
|
|
|
|
agentGuiNodeViewSpy.mockClear();
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
workspace={{
|
|
...props.workspace,
|
|
resolveReferenceContentErrorAction: secondResolver
|
|
}}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
agentGuiNodeViewSpy.mock.calls.at(-1)?.[0]
|
|
.resolveReferenceContentErrorAction
|
|
).toBe(secondResolver);
|
|
});
|
|
|
|
it("keeps the rail resize callback stable and reads the latest frame width", () => {
|
|
mockViewModel = createViewModel();
|
|
const onUpdateNode = vi.fn();
|
|
const initial = createProps();
|
|
const props = {
|
|
...initial,
|
|
hostActions: { ...initial.hostActions, onUpdateNode }
|
|
};
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
const firstViewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
onConversationRailWidthChanged: (widthPx: number) => void;
|
|
}
|
|
| undefined;
|
|
expect(firstViewProps).toBeDefined();
|
|
const firstCallback = firstViewProps!.onConversationRailWidthChanged;
|
|
|
|
rerender(
|
|
<AgentGUINode {...props} frame={{ ...props.frame, width: 640 }} />
|
|
);
|
|
const secondViewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
onConversationRailWidthChanged: (widthPx: number) => void;
|
|
}
|
|
| undefined;
|
|
expect(secondViewProps).toBeDefined();
|
|
const secondCallback = secondViewProps!.onConversationRailWidthChanged;
|
|
|
|
expect(secondCallback).toBe(firstCallback);
|
|
firstCallback(600);
|
|
const updater = onUpdateNode.mock.calls.at(-1)?.[0] as (
|
|
current: AgentGUINodeData
|
|
) => AgentGUINodeData;
|
|
const current = createState({ conversationRailWidthPx: 320 });
|
|
expect(updater(current).conversationRailWidthPx).toBe(
|
|
resolveNextAgentGUIConversationRailWidthPx({
|
|
currentWidthPx: current.conversationRailWidthPx,
|
|
requestedWidthPx: 600,
|
|
containerWidthPx: 640
|
|
})
|
|
);
|
|
expect(updater(current).conversationRailWidthPx).not.toBe(
|
|
resolveNextAgentGUIConversationRailWidthPx({
|
|
currentWidthPx: current.conversationRailWidthPx,
|
|
requestedWidthPx: 600,
|
|
containerWidthPx: props.frame.width
|
|
})
|
|
);
|
|
});
|
|
|
|
it("passes host rail layout observers through the memo boundary", () => {
|
|
mockViewModel = createViewModel();
|
|
const firstObserver = vi.fn();
|
|
const secondObserver = vi.fn();
|
|
const initial = createProps();
|
|
const props = {
|
|
...initial,
|
|
hostActions: {
|
|
...initial.hostActions,
|
|
onConversationRailLayoutChange: firstObserver
|
|
}
|
|
};
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
const firstViewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
onConversationRailLayoutChange?: (layout: unknown) => void;
|
|
}
|
|
| undefined;
|
|
expect(firstViewProps?.onConversationRailLayoutChange).toBe(firstObserver);
|
|
|
|
agentGuiNodeViewSpy.mockClear();
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
hostActions={{
|
|
...props.hostActions,
|
|
onConversationRailLayoutChange: secondObserver
|
|
}}
|
|
/>
|
|
);
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
const secondViewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
onConversationRailLayoutChange?: (layout: unknown) => void;
|
|
}
|
|
| undefined;
|
|
expect(secondViewProps?.onConversationRailLayoutChange).toBe(
|
|
secondObserver
|
|
);
|
|
});
|
|
|
|
it("passes project directory picker header actions through the memo boundary", () => {
|
|
mockViewModel = createViewModel();
|
|
const firstRenderer = vi.fn();
|
|
const secondRenderer = vi.fn();
|
|
const initial = createProps();
|
|
const props = {
|
|
...initial,
|
|
renderSlots: {
|
|
...initial.renderSlots,
|
|
projectDirectoryPickerHeaderActions: firstRenderer
|
|
}
|
|
};
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
|
|
expect(
|
|
agentGuiNodeViewSpy.mock.calls.at(-1)?.[0]
|
|
.renderProjectDirectoryPickerHeaderActions
|
|
).toBe(firstRenderer);
|
|
|
|
agentGuiNodeViewSpy.mockClear();
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
renderSlots={{
|
|
...props.renderSlots,
|
|
projectDirectoryPickerHeaderActions: secondRenderer
|
|
}}
|
|
/>
|
|
);
|
|
|
|
expect(agentGuiNodeViewSpy).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
agentGuiNodeViewSpy.mock.calls.at(-1)?.[0]
|
|
.renderProjectDirectoryPickerHeaderActions
|
|
).toBe(secondRenderer);
|
|
});
|
|
|
|
it("keeps rail labels stable when provider-facing labels change", () => {
|
|
mockViewModel = createViewModel();
|
|
const props = createProps();
|
|
const { rerender } = render(<AgentGUINode {...props} />);
|
|
const firstViewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
conversationRailLabels: unknown;
|
|
labels: unknown;
|
|
}
|
|
| undefined;
|
|
expect(firstViewProps).toBeDefined();
|
|
|
|
mockViewModel = createViewModel({
|
|
selectedAgentTarget: createLocalAgentGUIAgentTarget("claude-code"),
|
|
agentTargets: [createLocalAgentGUIAgentTarget("claude-code")]
|
|
});
|
|
rerender(
|
|
<AgentGUINode
|
|
{...props}
|
|
state={createState({ provider: "claude-code" })}
|
|
/>
|
|
);
|
|
const secondViewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
conversationRailLabels: unknown;
|
|
labels: unknown;
|
|
}
|
|
| undefined;
|
|
expect(secondViewProps).toBeDefined();
|
|
|
|
expect(secondViewProps!.labels).not.toBe(firstViewProps!.labels);
|
|
expect(secondViewProps!.conversationRailLabels).toBe(
|
|
firstViewProps!.conversationRailLabels
|
|
);
|
|
});
|
|
|
|
it("uses the exact active Agent target label in provider-facing copy", () => {
|
|
const kimiTarget: AgentGUIAgentTarget = {
|
|
agentTargetId: "extension:kimi-code",
|
|
label: "Kimi Code",
|
|
provider: "acp:kimi-code",
|
|
ref: {
|
|
kind: "agent_extension",
|
|
provider: "acp:kimi-code"
|
|
},
|
|
targetId: "extension:kimi-code"
|
|
};
|
|
const activeConversation: AgentGUIConversationSummary = {
|
|
agentTargetId: kimiTarget.agentTargetId,
|
|
cwd: "/workspace",
|
|
id: "session-1",
|
|
provider: kimiTarget.provider,
|
|
status: "ready",
|
|
title: "Hello",
|
|
updatedAtUnixMs: 1
|
|
};
|
|
mockViewModel = createViewModel({
|
|
activeConversation,
|
|
activeConversationId: activeConversation.id,
|
|
agentTargets: [kimiTarget]
|
|
});
|
|
|
|
render(<AgentGUINode {...createProps()} />);
|
|
|
|
const viewProps = agentGuiNodeViewSpy.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
labels: {
|
|
followupPlaceholder: string;
|
|
};
|
|
}
|
|
| undefined;
|
|
expect(viewProps?.labels.followupPlaceholder).toBe(
|
|
"Request Kimi Code to continue"
|
|
);
|
|
});
|
|
});
|
|
|
|
function createStatusControllerStub() {
|
|
const snapshot = {
|
|
query: null,
|
|
value: null,
|
|
phase: "idle" as const,
|
|
isRefreshing: false,
|
|
errorCode: null
|
|
};
|
|
return {
|
|
close: vi.fn(),
|
|
getSnapshot: () => snapshot,
|
|
invalidate: vi.fn(),
|
|
open: vi.fn(),
|
|
subscribe: () => () => {}
|
|
};
|
|
}
|
|
|
|
function createProps(
|
|
overrides: Partial<Parameters<typeof AgentGUINode>[0]> = {}
|
|
): Parameters<typeof AgentGUINode>[0] {
|
|
return {
|
|
identity: {
|
|
nodeId: "agent-gui-1",
|
|
workspaceId: "room-1",
|
|
currentUserId: "user-1",
|
|
title: "Codex"
|
|
},
|
|
workspace: {
|
|
path: "/workspace",
|
|
agentSettings: { avoidGroupingEdits: false }
|
|
},
|
|
frame: {
|
|
position: { x: 80, y: 56 },
|
|
width: 880,
|
|
height: 520,
|
|
desktopSize: { width: 1280, height: 720 },
|
|
isActive: true
|
|
},
|
|
state: createState(),
|
|
runtimeRequests: {},
|
|
hostCapabilities: {},
|
|
hostActions: {
|
|
onClose: vi.fn(),
|
|
onResize: vi.fn<(frame: NodeFrame) => void>(),
|
|
onUpdateNode: vi.fn()
|
|
},
|
|
renderSlots: {},
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createState(
|
|
overrides: Partial<AgentGUINodeData> = {}
|
|
): AgentGUINodeData {
|
|
return {
|
|
provider: "codex",
|
|
lastActiveAgentSessionId: null,
|
|
conversationRailWidthPx: null,
|
|
conversationRailCollapsed: false,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createViewModel(
|
|
overrides: AgentGUINodeViewModelFixtureOverrides = {}
|
|
): AgentGUINodeViewModel {
|
|
const draftContent: AgentComposerDraft = buildAgentComposerDraft({
|
|
prompt: ""
|
|
});
|
|
return groupAgentGUINodeViewModelFixture({
|
|
workspaceId: "room-1",
|
|
data: createState(),
|
|
activeConversationId: null,
|
|
activeConversation: null,
|
|
conversations: [],
|
|
userProjects: [],
|
|
conversation: null,
|
|
conversationDetail: null,
|
|
selectedAgentTarget: createLocalAgentGUIAgentTarget("codex"),
|
|
agentTargets: [createLocalAgentGUIAgentTarget("codex")],
|
|
agentTargetsLoading: false,
|
|
conversationFilter: { kind: "all" },
|
|
draftPrompt: "",
|
|
draftContent,
|
|
sessionChrome: {
|
|
auth: null,
|
|
approval: null,
|
|
recovery: null,
|
|
rawState: null
|
|
},
|
|
pendingInteractivePrompt: null,
|
|
queuedPrompts: [],
|
|
queueStatus: "active",
|
|
gate: {
|
|
conversationBusy: false,
|
|
isAwaitingTurnStart: false,
|
|
runtime: {
|
|
status: "ready",
|
|
reason: null,
|
|
sessionRuntimeReason: null
|
|
},
|
|
editor: { status: "editable", reason: null },
|
|
submission: { status: "ready", reason: null }
|
|
},
|
|
isSubmitting: false,
|
|
isInterrupting: false,
|
|
hasPendingSubmitStopTarget: false,
|
|
promptImagesSupported: true,
|
|
availability: "ready",
|
|
listError: null,
|
|
isCreatingConversation: false,
|
|
isLoadingConversations: false,
|
|
isLoadingMessages: false,
|
|
detailError: null,
|
|
deletingConversationId: null,
|
|
deletingConversationTitle: null,
|
|
composerSettings: {
|
|
sessionSettings: null,
|
|
draftSettings: {
|
|
model: null,
|
|
reasoningEffort: null,
|
|
planMode: false,
|
|
permissionModeId: "full-access"
|
|
},
|
|
defaultModel: null,
|
|
defaultReasoningEffort: null,
|
|
supportsModel: false,
|
|
supportsReasoningEffort: true,
|
|
supportsPlanMode: true,
|
|
isSettingsLoading: false,
|
|
modelUnavailable: false,
|
|
reasoningUnavailable: false,
|
|
availableModels: [],
|
|
availableReasoningEfforts: []
|
|
},
|
|
availableCommands: [],
|
|
availableSkills: [],
|
|
...overrides
|
|
} as FlatAgentGUINodeViewModelFixture);
|
|
}
|