Files
tutti/packages/agent/gui/AgentGUIQuickComposer.spec.tsx
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

712 lines
22 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentActivityComposerOptions } from "@tutti-os/agent-activity-core";
import { createI18nRuntime } from "@tutti-os/ui-i18n-runtime";
import { createRichTextMentionService } from "@tutti-os/ui-rich-text/service";
import type { RichTextTriggerProvider } from "@tutti-os/ui-rich-text/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AgentGUIQuickComposer } from "./AgentGUIQuickComposer";
import type { AgentGUIQuickComposerAgentTarget } from "./AgentGUIQuickComposer";
import { agentGuiI18nResources } from "./i18n/index";
import { projectQuickComposerSettings } from "./quickComposerSettings";
const agentTargets = [
{
agentTargetId: "agent:codex",
iconUrl: "/codex.png",
label: "Codex",
provider: "codex"
}
] satisfies AgentGUIQuickComposerAgentTarget[];
const capabilitiesByAgentTargetId = {
"agent:codex": { imageInput: true, workspaceReferences: true }
} as const;
const composerOptions = {
behavior: {
collapseModelOptionsToLatest: false,
modelOptionsAuthoritative: true,
planModeExclusiveWithPermissionMode: false,
prewarmDraftSession: false,
refreshModelOptionsAfterSettings: true
},
capabilities: null,
effectiveSettings: {
model: "gpt-5.6-sol",
reasoningEffort: "high"
},
loadedAtUnixMs: 1,
modelConfigurable: true,
models: [
{ label: "GPT-5.6-Sol", value: "gpt-5.6-sol" },
{ label: "GPT-5.5", value: "gpt-5.5" }
],
provider: "codex",
reasoningConfigurable: true,
reasoningEfforts: [
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" }
],
skills: [],
speeds: []
} satisfies AgentActivityComposerOptions;
beforeEach(() => {
globalThis.localStorage.clear();
});
describe("AgentGUIQuickComposer", () => {
it("fails closed when the controlled Agent target is missing or disabled", () => {
const { container, rerender } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:missing"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(
container.querySelector<HTMLButtonElement>(
'[data-testid="agent-gui-composer-send"]'
)?.disabled
).toBe(true);
rerender(
<AgentGUIQuickComposer
agentTargets={[{ ...agentTargets[0]!, disabled: true }]}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(
container.querySelector<HTMLButtonElement>(
'[data-testid="agent-gui-composer-send"]'
)?.disabled
).toBe(true);
});
it("submits the exact resolved Agent target identity", () => {
const onSubmit = vi.fn();
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={onSubmit}
/>
);
fireEvent.click(
container.querySelector<HTMLButtonElement>(
'[data-testid="agent-gui-composer-send"]'
)!
);
expect(onSubmit).toHaveBeenCalledWith({
agentTargetId: "agent:codex",
content: [{ text: "Inspect this", type: "text" }],
displayPrompt: "Inspect this"
});
});
it("reuses the standard model and reasoning controls", async () => {
const onSettingsChange = vi.fn();
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
composerSettings={{
loading: false,
onChange: onSettingsChange,
options: composerOptions,
value: { model: "gpt-5.6-sol", reasoningEffort: "high" }
}}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const trigger = container.querySelector<HTMLButtonElement>(
'[data-agent-model-reasoning-trigger="true"]'
);
expect(trigger).toHaveTextContent("GPT-5.6-Sol");
expect(trigger).toHaveTextContent("High");
fireEvent.pointerDown(trigger!, { button: 0, ctrlKey: false });
await waitFor(() =>
expect(
document.querySelector('[data-agent-model-value="gpt-5.5"]')
).not.toBeNull()
);
fireEvent.pointerDown(
document.querySelector<HTMLElement>(
'[data-agent-model-value="gpt-5.5"]'
)!,
{ button: 0 }
);
expect(onSettingsChange).toHaveBeenCalledWith({ model: "gpt-5.5" });
expect(
globalThis.localStorage.getItem(
"agent-gui:composer-model-recents:agent:codex"
)
).toBe('["gpt-5.5"]');
expect(
globalThis.localStorage.getItem(
"agent-gui:composer-model-recents:default"
)
).toBeNull();
});
it("migrates legacy model history into the exact selected target", async () => {
globalThis.localStorage.setItem(
"agent-gui:composer-model-recents:default",
'["obsolete-model","gpt-5.5"]'
);
globalThis.localStorage.setItem(
"agent-gui:composer-model-favorites:default",
'["gpt-5.5"]'
);
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
composerSettings={{
loading: false,
onChange: vi.fn(),
options: composerOptions,
value: { model: "gpt-5.6-sol", reasoningEffort: "high" }
}}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
fireEvent.pointerDown(
container.querySelector<HTMLButtonElement>(
'[data-agent-model-reasoning-trigger="true"]'
)!,
{ button: 0, ctrlKey: false }
);
await waitFor(() => {
expect(
globalThis.localStorage.getItem(
"agent-gui:composer-model-recents:agent:codex"
)
).toBe('["gpt-5.5"]');
expect(
globalThis.localStorage.getItem(
"agent-gui:composer-model-favorites:agent:codex"
)
).toBe('["gpt-5.5"]');
});
expect(
globalThis.localStorage.getItem(
"agent-gui:composer-model-recents:default"
)
).toBeNull();
expect(
globalThis.localStorage.getItem(
"agent-gui:composer-model-favorites:default"
)
).toBeNull();
expect(
document.querySelector(
'[data-agent-model-value="gpt-5.5"] [data-agent-model-favorite-toggle="true"]'
)
).toHaveAttribute("data-favorited", "true");
});
it("marks retained catalog testimony unsettled while the host is loading", () => {
const projected = projectQuickComposerSettings({
agentTargetId: "agent:codex",
loading: true,
options: composerOptions,
projectLocked: false,
provider: "codex",
selectedProjectPath: null,
settings: {}
});
expect(projected.modelChoiceHistory).toEqual({
targetId: "agent:codex",
catalog: {
authoritative: true,
effectiveModel: "gpt-5.6-sol",
loading: true,
models: [{ value: "gpt-5.6-sol" }, { value: "gpt-5.5" }]
}
});
});
it("keeps prompt entry and references available while options load", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
composerSettings={{
loading: true,
onChange: vi.fn(),
options: null,
value: {}
}}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onRequestWorkspaceReferences={vi.fn().mockResolvedValue({
files: [],
mentionItems: []
})}
onSubmit={vi.fn()}
/>
);
expect(
container.querySelector<HTMLElement>('[role="textbox"]')
).toHaveAttribute("aria-disabled", "false");
expect(
container
.querySelector('[data-agent-reference-add-icon="true"]')
?.closest("button")
).not.toBeDisabled();
expect(
container.querySelector<HTMLButtonElement>(
'[data-agent-model-reasoning-trigger="true"]'
)
).toBeDisabled();
expect(
container.querySelector<HTMLButtonElement>(
'[data-testid="agent-gui-composer-send"]'
)
).toBeDisabled();
});
it("hides settings controls when the host omits the controlled capability", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(
container.querySelector('[data-agent-model-reasoning-trigger="true"]')
).toBeNull();
});
it("maps provider selection to the canonical Agent target identity", () => {
const onAgentTargetChange = vi.fn();
render(
<AgentGUIQuickComposer
agentTargets={[
...agentTargets,
{
agentTargetId: "agent:claude",
label: "Claude Code",
provider: "claude-code"
}
]}
capabilitiesByAgentTargetId={{
...capabilitiesByAgentTargetId,
"agent:claude": { imageInput: true, workspaceReferences: true }
}}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={onAgentTargetChange}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("combobox", { name: "Switch provider" }));
fireEvent.click(screen.getByRole("option", { name: "Claude Code" }));
expect(onAgentTargetChange).toHaveBeenCalledWith("agent:claude");
});
it("fails closed for unknown or image-incompatible target capabilities", () => {
const imageContent = [
{
data: "iVBORw0KGgo=",
mimeType: "image/png" as const,
type: "image" as const
}
];
const { container, rerender } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={{}}
content={imageContent}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const send = container.querySelector<HTMLButtonElement>(
'[data-testid="agent-gui-composer-send"]'
);
expect(send?.disabled).toBe(true);
rerender(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={{
"agent:codex": { imageInput: false, workspaceReferences: true }
}}
content={imageContent}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(send?.disabled).toBe(true);
});
it("uses a host-injected i18n runtime", () => {
const i18n = createI18nRuntime({
dictionaries: [agentGuiI18nResources.en]
});
const translate = vi.spyOn(i18n, "t");
render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "", type: "text" }]}
i18n={i18n}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(translate).toHaveBeenCalledWith(
"agentHost.agentGui.initialPlaceholder",
expect.any(Object)
);
});
it("fills host-owned height only when requested", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "", type: "text" }]}
fillAvailableHeight={true}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const composer = container.querySelector(
'form[data-layout="embedded"][data-fill-available-height="true"]'
);
expect(composer).not.toBeNull();
expect(
composer?.querySelector(".agent-gui-node__rich-text-editor-surface")
).not.toBeNull();
expect(
composer?.querySelector(".agent-gui-node__rich-text-editor-content")
).not.toBeNull();
});
it("uses the in-flow embedded layout for image drafts", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[
{ text: "Inspect this screenshot", type: "text" },
{
data: "iVBORw0KGgo=",
mimeType: "image/png",
name: "screenshot.png",
type: "image"
}
]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const composer = container.querySelector<HTMLFormElement>(
'form[data-layout="embedded"]'
);
const promptInputArea = composer?.querySelector(
".agent-gui-node__composer-prompt-input-area"
);
expect(composer).not.toBeNull();
expect(
promptInputArea?.querySelector(
'[data-testid="agent-gui-composer-image-draft"]'
)
).not.toBeNull();
});
it("enables workspace references when the embedding host supplies the picker", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onRequestWorkspaceReferences={vi.fn().mockResolvedValue({
files: [],
mentionItems: []
})}
onSubmit={vi.fn()}
/>
);
const addIcon = container.querySelector(
'[data-agent-reference-add-icon="true"]'
);
expect(addIcon?.closest("button")?.hasAttribute("disabled")).toBe(false);
});
it("shows the canonical project selector only with a real project capability", async () => {
const project = {
id: "project-alpha",
label: "Alpha",
path: "/workspace/alpha",
pinnedAtUnixMs: 0
};
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "", type: "text" }]}
locale="zh-CN"
selectedAgentTargetId="agent:codex"
selectedProjectPath={null}
userProjectApi={{
list: vi.fn().mockResolvedValue({ projects: [project] }),
prepareSelection: vi.fn().mockResolvedValue({
isSelectedPathMissing: false,
projects: [project],
selection: { kind: "none" }
}),
selectDirectory: vi.fn().mockResolvedValue({ path: project.path }),
use: vi.fn().mockResolvedValue(project)
}}
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onProjectPathChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const noProjectIcon = container.querySelector(
'[data-agent-project-trigger-no-workspace-icon="true"]'
);
await waitFor(() =>
expect(noProjectIcon?.closest("button")?.hasAttribute("disabled")).toBe(
false
)
);
expect(noProjectIcon?.closest("button")).toHaveTextContent("不使用项目");
expect(
container
.querySelector(".agent-gui-node__composer-footer")
?.contains(noProjectIcon)
).toBe(true);
});
it("does not fabricate a project catalog for a directory-only embedding", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "", type: "text" }]}
selectedAgentTargetId="agent:codex"
selectProjectDirectory={vi.fn().mockResolvedValue({
path: "/workspace/alpha"
})}
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onProjectPathChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(
container.querySelector(
'[data-agent-project-trigger-no-workspace-icon="true"]'
)
).toBeNull();
});
it("renders a host action accessory beside send inside the AgentGUI token scope", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
composerActionAccessory={
<span data-testid="quick-composer-action-accessory">Track Task</span>
}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const scope = container.querySelector(".agent-gui-node__shell");
const accessory = container.querySelector(
'[data-testid="quick-composer-action-accessory"]'
);
const send = container.querySelector(
'[data-testid="agent-gui-composer-send"]'
);
expect(scope).not.toBeNull();
expect(scope?.contains(accessory)).toBe(true);
expect(accessory?.parentElement).toBe(send?.parentElement);
});
it("can place the host action cluster in the Composer footer", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
composerActionAccessory={
<span data-testid="quick-composer-footer-accessory">Track Task</span>
}
composerActionPlacement="footer"
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
const footer = container.querySelector(
".agent-gui-node__composer-footer-right"
);
const accessory = container.querySelector(
'[data-testid="quick-composer-footer-accessory"]'
);
const send = container.querySelector(
'[data-testid="agent-gui-composer-send"]'
);
expect(footer?.contains(accessory)).toBe(true);
expect(footer?.contains(send)).toBe(true);
expect(accessory?.parentElement).toBe(send?.parentElement);
});
it("hides the connector capability control quick-composer hosts cannot service", () => {
const { container } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "Inspect this", type: "text" }]}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(
container.querySelector(
'[data-testid="agent-gui-composer-connectors-trigger"]'
)
).toBeNull();
});
it("installs the mention service supplied by the embedding host", () => {
const query = vi.fn().mockResolvedValue([]);
const provider: RichTextTriggerProvider<{ id: string; label: string }> = {
id: "file",
trigger: "@",
query,
getItemKey: (item) => item.id,
getItemLabel: (item) => item.label,
toInsertResult: (item) => ({
href: `/workspace/${item.id}`,
kind: "markdown-link",
label: item.label
})
};
const mentionService = createRichTextMentionService({
providers: [provider]
});
const listProviders = vi.spyOn(mentionService, "listProviders");
const { unmount } = render(
<AgentGUIQuickComposer
agentTargets={agentTargets}
capabilitiesByAgentTargetId={capabilitiesByAgentTargetId}
content={[{ text: "", type: "text" }]}
mentionService={mentionService}
selectedAgentTargetId="agent:codex"
workspaceId="workspace:test"
onAgentTargetChange={vi.fn()}
onContentChange={vi.fn()}
onSubmit={vi.fn()}
/>
);
expect(listProviders).toHaveBeenCalled();
unmount();
mentionService.dispose();
});
});