Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIComposerOptionsSync.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

721 lines
25 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentGUIRuntime } from "../../../agentActivityRuntime";
import type { AgentGUIProvider, AgentGUINodeData } from "../../../types";
import { setAgentHostApiForTests } from "../../../agentActivityHost";
import type { AgentHostRuntimeApi } from "../../../host/agentHostApi";
import type { AgentGUIComposerTargetData } from "./agentGuiController.composerPresentation";
import type { AgentGUIComposerDefaultsAuthorityReconciler } from "./agentGuiComposerDefaultsReconciliation";
import { useAgentGUIComposerOptionsSync } from "./useAgentGUIComposerOptionsSync";
describe("useAgentGUIComposerOptionsSync", () => {
it("waits for the authoritative model catalog when prewarming a draft session", async () => {
const getComposerOptions = vi.fn(async () => ({}));
const data = targetData("opencode");
const target = composerTarget("opencode");
const rendered = renderHook(() =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: null,
activeConversationId: null,
activeConversationIdRef: { current: null },
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: true,
isComposerHomeRef: { current: true },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef:
createComposerDefaultsAuthorityReconcilerRef(),
optimisticComposerTarget: null,
providerComposerOptions: {
behavior: { prewarmDraftSession: true }
},
selectedComposerTargetDataRef: { current: target },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
})
);
try {
await waitFor(() =>
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:opencode",
force: true,
waitForFreshModelCatalog: true
})
)
);
} finally {
rendered.unmount();
}
});
it("loads a switched target once without bypassing its cache", async () => {
const getComposerOptions = vi.fn(async () => ({}));
const activeConversationIdRef = { current: null };
const dataRef = { current: targetData("codex") };
const selectedTargetRef = { current: composerTarget("codex") };
const selectedProjectPathRef = { current: "/workspace/project" };
const authorityReconcilerRef =
createComposerDefaultsAuthorityReconcilerRef();
const composerOptionsByTargetKey = {
"local:codex": { provider: "codex" },
"local:claude-code": { provider: "claude-code" }
};
const { result, rerender } = renderHook(
({ provider }) => {
const target = composerTarget(provider);
dataRef.current = target.data;
selectedTargetRef.current = target;
return useAgentGUIComposerOptionsSync({
activeSessionTarget: null,
activeConversationId: null,
activeConversationIdRef,
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({ composerOptionsByTargetKey })
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data: target.data,
dataRef,
defaultReasoningEffort: "high",
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: true,
isComposerHomeRef: { current: true },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef: authorityReconcilerRef,
providerComposerOptions: null,
selectedComposerTargetDataRef: selectedTargetRef,
selectedProjectPath: "/workspace/project",
selectedProjectPathRef,
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
});
},
{ initialProps: { provider: "codex" as AgentGUIProvider } }
);
await waitFor(() => {
expect(getComposerOptions).toHaveBeenCalledTimes(1);
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({ section: "core" })
);
expect(
authorityReconcilerRef.current.reconcileHomeDefaults
).toHaveBeenCalledWith(
expect.objectContaining({ agentTargetId: "local:codex" }),
composerOptionsByTargetKey["local:codex"]
);
});
getComposerOptions.mockClear();
vi.mocked(authorityReconcilerRef.current.reconcileHomeDefaults).mockClear();
rerender({ provider: "claude-code" });
await waitFor(() => expect(getComposerOptions).toHaveBeenCalledTimes(1));
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:claude-code",
force: undefined,
provider: "claude-code"
})
);
expect(
authorityReconcilerRef.current.reconcileHomeDefaults
).toHaveBeenCalledWith(
expect.objectContaining({ agentTargetId: "local:claude-code" }),
composerOptionsByTargetKey["local:claude-code"]
);
getComposerOptions.mockClear();
await result.current.reloadComposerOptionsForTarget({
settings: { planMode: false },
target: selectedTargetRef.current
});
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:claude-code",
force: true,
settings: { planMode: false }
})
);
});
it("loads composer options after conversation creation settles", async () => {
const getComposerOptions = vi.fn(async () => ({}));
const data: AgentGUINodeData = {
provider: "codex",
agentTargetId: "local:codex",
lastActiveAgentSessionId: null
};
const target: AgentGUIComposerTargetData = {
agentTargetId: "local:codex",
data,
provider: "codex",
targetId: "local:codex"
};
const activeConversationIdRef = { current: null };
const dataRef = { current: data };
const selectedTargetRef = { current: target };
const selectedProjectPathRef = { current: "/workspace/project" };
const { rerender } = renderHook(
({ isCreatingConversation }) =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: null,
activeConversationId: null,
activeConversationIdRef,
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef,
defaultReasoningEffort: "high",
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: true,
isComposerHomeRef: { current: true },
isCreatingConversation,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef:
createComposerDefaultsAuthorityReconcilerRef(),
providerComposerOptions: null,
selectedComposerTargetDataRef: selectedTargetRef,
selectedProjectPath: "/workspace/project",
selectedProjectPathRef,
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
}),
{ initialProps: { isCreatingConversation: true } }
);
expect(getComposerOptions).not.toHaveBeenCalled();
rerender({ isCreatingConversation: false });
await waitFor(() => {
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:codex",
cwd: "/workspace/project",
force: true,
provider: "codex",
workspaceId: "workspace-1"
})
);
});
});
it("does not force composer options when switching hydrated sessions", async () => {
const getComposerOptions = vi.fn(async () => ({}));
const data = targetData("codex");
const target = composerTarget("codex");
const activeConversationIdRef = { current: "session-1" };
const { rerender } = renderHook(
({ activeConversationId }) => {
activeConversationIdRef.current = activeConversationId;
return useAgentGUIComposerOptionsSync({
activeSessionTarget: {
...target,
agentSessionId: activeConversationId
},
activeConversationId,
activeConversationIdRef,
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: false,
isComposerHomeRef: { current: false },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef:
createComposerDefaultsAuthorityReconcilerRef(),
providerComposerOptions: null,
selectedComposerTargetDataRef: { current: target },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
});
},
{ initialProps: { activeConversationId: "session-1" } }
);
await waitFor(() => expect(getComposerOptions).toHaveBeenCalledTimes(1));
getComposerOptions.mockClear();
rerender({ activeConversationId: "session-2" });
await waitFor(() => expect(getComposerOptions).toHaveBeenCalledTimes(1));
expect(getComposerOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ force: undefined })
);
});
it("loads options from one canonical active-session target identity", async () => {
const getComposerOptions = vi.fn(async () => ({}));
const provider = "acp:hermes" as AgentGUIProvider;
const data: AgentGUINodeData = {
agentTargetId: "local:opencode",
provider: "opencode",
lastActiveAgentSessionId: "session-1"
};
const selectedTarget: AgentGUIComposerTargetData = {
agentTargetId: "local:opencode",
data,
provider: "opencode",
targetId: "local:opencode"
};
renderHook(() =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: {
agentTargetId: "extension:hermes",
agentSessionId: "session-1",
provider
},
activeConversationId: "session-1",
activeConversationIdRef: { current: "session-1" },
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: selectedTarget,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: false,
isComposerHomeRef: { current: false },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef:
createComposerDefaultsAuthorityReconcilerRef(),
providerComposerOptions: null,
selectedComposerTargetDataRef: { current: selectedTarget },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
})
);
await waitFor(() =>
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "extension:hermes",
provider
})
)
);
});
it("force reloads composer options when the connector catalog changes", async () => {
const getComposerOptions = vi.fn(async () => ({}));
let emitHostEvent: ((event: unknown) => void) | null = null;
setAgentHostApiForTests({
onHostEvent: (listener: (event: unknown) => void) => {
emitHostEvent = listener;
return () => {
emitHostEvent = null;
};
}
} as unknown as AgentHostRuntimeApi);
const data = targetData("codex");
const target = composerTarget("codex");
const rendered = renderHook(() =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: {
...target,
agentSessionId: "session-1"
},
activeConversationId: "session-1",
activeConversationIdRef: { current: "session-1" },
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: false,
isComposerHomeRef: { current: false },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef:
createComposerDefaultsAuthorityReconcilerRef(),
providerComposerOptions: null,
selectedComposerTargetDataRef: { current: target },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
})
);
try {
await waitFor(() => expect(getComposerOptions).toHaveBeenCalled());
getComposerOptions.mockClear();
act(() => {
emitHostEvent?.({
connectorKey: "lark-cli",
revision: 7,
scope: "global",
type: "agent-connector-catalog-invalidated"
});
});
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:codex",
force: true,
provider: "codex",
section: "connectors"
})
);
} finally {
rendered.unmount();
setAgentHostApiForTests(null);
}
});
it("rereads target authority on invalidation without sending local persistent intent", async () => {
const getComposerOptions = vi.fn(async () => ({}));
let emitHostEvent: ((event: unknown) => void) | null = null;
setAgentHostApiForTests({
onHostEvent: (listener: (event: unknown) => void) => {
emitHostEvent = listener;
return () => {
emitHostEvent = null;
};
}
} as unknown as AgentHostRuntimeApi);
const data = targetData("opencode");
const target = composerTarget("opencode");
const draftSettingsBySessionIdRef = {
current: {
"__agent_gui_node_defaults__:target:local:opencode": {
model: "opencode/new-model",
permissionModeId: "full-access",
planMode: false,
reasoningEffort: "high" as const,
speed: "fast" as const
}
}
};
const authorityReconcilerRef =
createComposerDefaultsAuthorityReconcilerRef();
const rendered = renderHook(() =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: null,
activeConversationId: null,
activeConversationIdRef: { current: null },
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef,
isComposerHome: true,
isComposerHomeRef: { current: true },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef: authorityReconcilerRef,
providerComposerOptions: null,
selectedComposerTargetDataRef: { current: target },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
})
);
try {
await waitFor(() => expect(getComposerOptions).toHaveBeenCalled());
getComposerOptions.mockClear();
const permissionReceipt = {
draftKey: "__agent_gui_node_defaults__:target:local:opencode",
fields: {
permissionModeId: {
generation: 1,
value: "full-access"
}
}
};
authorityReconcilerRef.current.prepareRead = vi.fn(
(_target, settings) => {
const authoritySettings = { ...settings };
delete authoritySettings.permissionModeId;
return {
force: true,
receipt: permissionReceipt,
settings: authoritySettings
};
}
);
vi.mocked(authorityReconcilerRef.current.reloaded).mockClear();
act(() => {
emitHostEvent?.({
agentTargetId: "local:opencode",
scope: "global",
type: "agent-composer-defaults-invalidated"
});
});
await waitFor(() =>
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:opencode",
force: true,
settings: { planMode: false }
})
)
);
expect(draftSettingsBySessionIdRef.current).toMatchObject({
"__agent_gui_node_defaults__:target:local:opencode": {
permissionModeId: "full-access"
}
});
expect(authorityReconcilerRef.current.prepareRead).toHaveBeenCalledWith(
target,
expect.objectContaining({
model: "opencode/new-model",
permissionModeId: "full-access",
reasoningEffort: "high",
speed: "fast"
})
);
expect(authorityReconcilerRef.current.reloaded).toHaveBeenCalledWith(
permissionReceipt,
{}
);
} finally {
rendered.unmount();
setAgentHostApiForTests(null);
}
});
it("forces a later home authority read to reconcile acknowledged defaults", async () => {
const returnedOptions = {
effectiveSettings: { permissionModeId: "default" }
};
const cachedOptions = {
effectiveSettings: { permissionModeId: "full-access" }
};
const getComposerOptions = vi.fn(async () => returnedOptions);
const data = targetData("opencode");
const target = composerTarget("opencode");
const authorityReconcilerRef =
createComposerDefaultsAuthorityReconcilerRef();
authorityReconcilerRef.current.prepareRead = vi.fn((_target, settings) => ({
force: true,
receipt: {
draftKey: "__agent_gui_node_defaults__:target:local:opencode",
fields: {
permissionModeId: {
generation: 1,
value: "full-access"
}
}
},
settings: { planMode: settings.planMode }
}));
renderHook(() =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: null,
activeConversationId: null,
activeConversationIdRef: { current: null },
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({
composerOptionsByTargetKey: {
"local:opencode": cachedOptions
}
})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef: {
current: {
"__agent_gui_node_defaults__:target:local:opencode": {
permissionModeId: "full-access",
planMode: false
}
}
},
isComposerHome: true,
isComposerHomeRef: { current: true },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef: authorityReconcilerRef,
providerComposerOptions: null,
selectedComposerTargetDataRef: { current: target },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
})
);
await waitFor(() =>
expect(getComposerOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentTargetId: "local:opencode",
force: true,
settings: { planMode: false }
})
)
);
expect(authorityReconcilerRef.current.reloaded).toHaveBeenCalledWith(
expect.objectContaining({
draftKey: "__agent_gui_node_defaults__:target:local:opencode"
}),
returnedOptions
);
expect(
authorityReconcilerRef.current.reconcileHomeDefaults
).toHaveBeenCalledWith(
expect.objectContaining({ agentTargetId: "local:opencode" }),
cachedOptions
);
});
it("does not reconcile target defaults while loading an active session", async () => {
const getComposerOptions = vi.fn(async () => ({}));
const data = targetData("opencode");
const target = composerTarget("opencode");
const authorityReconcilerRef =
createComposerDefaultsAuthorityReconcilerRef();
renderHook(() =>
useAgentGUIComposerOptionsSync({
activeSessionTarget: {
...target,
agentSessionId: "session-1"
},
activeConversationId: "session-1",
activeConversationIdRef: { current: "session-1" },
agentActivityRuntime: {
getComposerOptions,
getSnapshot: () => ({})
} as unknown as AgentGUIRuntime,
composerTargetData: target,
conversationFilter: null,
currentUserId: "user-1",
data,
dataRef: { current: data },
defaultReasoningEffort: null,
draftSettingsBySessionIdRef: { current: {} },
isComposerHome: false,
isComposerHomeRef: { current: false },
isCreatingConversation: false,
loadDraftComposerOptionsRef: { current: () => {} },
loadSessionState: vi.fn(),
onComposerDefaultsAuthorityReloadedRef: authorityReconcilerRef,
providerComposerOptions: null,
selectedComposerTargetDataRef: { current: target },
selectedProjectPath: "/workspace/project",
selectedProjectPathRef: { current: "/workspace/project" },
syncConversationListProjection: vi.fn(async () => {}),
workspaceId: "workspace-1",
workspacePath: "/workspace"
})
);
await waitFor(() => expect(getComposerOptions).toHaveBeenCalled());
expect(authorityReconcilerRef.current.prepareRead).not.toHaveBeenCalled();
expect(authorityReconcilerRef.current.reloaded).not.toHaveBeenCalled();
});
});
function createComposerDefaultsAuthorityReconcilerRef(): {
current: AgentGUIComposerDefaultsAuthorityReconciler;
} {
return {
current: {
prepareRead: vi.fn((_target, settings) => ({
force: false,
receipt: null,
settings
})),
reconcileHomeDefaults: vi.fn(),
reloaded: vi.fn()
}
};
}
function targetData(provider: AgentGUIProvider): AgentGUINodeData {
return {
agentTargetId: `local:${provider}`,
lastActiveAgentSessionId: null,
provider
};
}
function composerTarget(
provider: AgentGUIProvider
): AgentGUIComposerTargetData {
return {
agentTargetId: `local:${provider}`,
data: targetData(provider),
provider,
targetId: `local:${provider}`
};
}