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

516 lines
17 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import {
createAgentSessionEngine,
type AgentActivityRailPlacement,
type EngineExternalCommand
} from "@tutti-os/agent-activity-core";
import { describe, expect, it, vi } from "vitest";
import { createLocalAgentGUIAgentTarget } from "../../../agentTargets";
import type { AgentGUIRuntime } from "../../../agentActivityRuntime";
import type { AgentHostUserProject } from "../../../host/agentHostApi";
import type { AgentSessionComposerSettings } from "../../../shared/agentSessionTypes";
import { createTestEngineCommandPort } from "../../../shared/testing/createTestAgentSessionEngine";
import type { AgentGUINodeData } from "../../../types";
import type { AgentGUIConversationSummary } from "../model/agentGuiConversationModel";
import type { AgentComposerDraft } from "../model/agentGuiNodeTypes";
import type { AgentGUIComposerTargetData } from "./agentGuiController.composerPresentation";
import { nodeDefaultDraftKey } from "./agentGuiController.composerHelpers";
import { requestAgentGUINewConversation } from "./agentGuiNewConversationRequest";
import { useAgentGUIActivation } from "./useAgentGUIActivation";
import { useAgentGUIConversationHome } from "./useAgentGUIConversationHome";
import { useAgentGUINewConversationActivation } from "./useAgentGUINewConversationActivation";
import { useAgentGUISubmitInteractionActions } from "./useAgentGUISubmitInteractionActions";
describe("P0 new-conversation placement scenarios", () => {
it("does not persist a provisional session before canonical confirmation", async () => {
const scenario = renderNewConversationScenario({
activeConversation: null,
initialHomeProjectPath: null,
userProjects: []
});
act(() => scenario.requestNewConversation());
scenario.persistActiveConversation.mockClear();
act(() =>
scenario.submitPrompt([{ type: "text", text: "start a new chat" }])
);
await scenario.waitForActivation();
expect(scenario.persistActiveConversation).not.toHaveBeenCalled();
});
it("creates a conversations-scoped activation from an active Chats session", async () => {
const previousSessionCwd = "/Users/example/Documents/tutti/session-current";
const scenario = renderNewConversationScenario({
activeConversation: conversationSummary({
cwd: previousSessionCwd,
railSectionKey: "conversations"
}),
initialHomeProjectPath: previousSessionCwd,
userProjects: []
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "start a new chat" }])
);
const activation = await scenario.waitForActivation();
expect(scenario.activateSession).toHaveBeenCalledTimes(1);
expect(activation).toMatchObject({
cwd: "",
initialContent: [{ type: "text", text: "start a new chat" }],
railPlacement: {
version: 1,
kind: "conversations",
sectionKey: "conversations"
}
});
expect(activation.cwd).not.toBe(previousSessionCwd);
});
it("keeps canonical project placement when starting from a project session", async () => {
const projectPath = "/workspace/project-a";
const sectionKey = "project:workspace-1:/workspace/project-a";
const scenario = renderNewConversationScenario({
activeConversation: conversationSummary({
cwd: projectPath,
railSectionKey: sectionKey
}),
initialHomeProjectPath: null,
userProjects: [
{
id: "project-a",
label: "Project A",
path: projectPath,
pinnedAtUnixMs: 0,
sectionKey
}
]
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "continue in project" }])
);
expect(await scenario.waitForActivation()).toMatchObject({
cwd: projectPath,
initialContent: [{ type: "text", text: "continue in project" }],
railPlacement: {
version: 1,
kind: "project",
projectPath,
sectionKey
}
});
});
it("fails closed when a remembered saver mode outlives the developer flag", async () => {
const scenario = renderNewConversationScenario({
activeConversation: null,
codexSaverModeEntryEnabled: false,
rtkSaverModeEntryEnabled: false,
initialHomeProjectPath: null,
rememberedSettings: { codexSaverMode: true, rtkSaverMode: true },
userProjects: []
});
act(() => scenario.requestNewConversation());
act(() => scenario.submitPrompt([{ type: "text", text: "start safely" }]));
expect(await scenario.waitForActivation()).toMatchObject({
settings: { codexSaverMode: false, rtkSaverMode: false }
});
});
it("keeps remembered saver mode when both entry and target support it", async () => {
const scenario = renderNewConversationScenario({
activeConversation: null,
codexSaverModeEntryEnabled: true,
initialHomeProjectPath: null,
rememberedSettings: { codexSaverMode: true, rtkSaverMode: true },
userProjects: []
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "delegate when useful" }])
);
expect(await scenario.waitForActivation()).toMatchObject({
settings: { codexSaverMode: true, rtkSaverMode: true }
});
});
it("uses the authoritative remembered saver mode when no local draft exists", async () => {
const scenario = renderNewConversationScenario({
activeConversation: null,
authoritativeSettings: { codexSaverMode: true },
codexSaverModeEntryEnabled: true,
initialHomeProjectPath: null,
userProjects: []
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "use remembered mode" }])
);
expect(await scenario.waitForActivation()).toMatchObject({
settings: { codexSaverMode: true }
});
});
it("keeps an explicit local saver opt-out above authoritative defaults", async () => {
const scenario = renderNewConversationScenario({
activeConversation: null,
authoritativeSettings: { codexSaverMode: true },
codexSaverModeEntryEnabled: true,
initialHomeProjectPath: null,
rememberedSettings: { codexSaverMode: false },
userProjects: []
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "keep saver disabled" }])
);
expect(await scenario.waitForActivation()).toMatchObject({
settings: { codexSaverMode: false }
});
});
it("keeps the logical project when the active session runs in an isolated worktree", async () => {
const projectPath = "/workspace/project-a";
const sectionKey = "project:workspace-1:/workspace/project-a";
const scenario = renderNewConversationScenario({
activeConversation: conversationSummary({
cwd: "/state/task-worktrees/issue/task-run",
railSectionKey: sectionKey
}),
initialHomeProjectPath: null,
userProjects: [
{
id: "project-a",
label: "Project A",
path: projectPath,
pinnedAtUnixMs: 0,
sectionKey
}
]
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "continue from worktree" }])
);
expect(await scenario.waitForActivation()).toMatchObject({
cwd: projectPath,
railPlacement: {
version: 1,
kind: "project",
projectPath,
sectionKey
}
});
});
it("preserves an explicit project selection already made on Home", async () => {
const projectPath = "/workspace/project-a";
const sectionKey = "project:workspace-1:/workspace/project-a";
const scenario = renderNewConversationScenario({
activeConversation: null,
initialHomeProjectPath: projectPath,
userProjects: [
{
id: "project-a",
label: "Project A",
path: projectPath,
pinnedAtUnixMs: 0,
sectionKey
}
]
});
act(() => scenario.requestNewConversation());
act(() =>
scenario.submitPrompt([{ type: "text", text: "start in my project" }])
);
expect(await scenario.waitForActivation()).toMatchObject({
cwd: projectPath,
initialContent: [{ type: "text", text: "start in my project" }],
railPlacement: {
version: 1,
kind: "project",
projectPath,
sectionKey
}
});
});
});
function renderNewConversationScenario(input: {
activeConversation: AgentGUIConversationSummary | null;
authoritativeSettings?: AgentSessionComposerSettings;
codexSaverModeEntryEnabled?: boolean;
rtkSaverModeEntryEnabled?: boolean;
initialHomeProjectPath: string | null;
rememberedSettings?: AgentSessionComposerSettings;
userProjects: AgentHostUserProject[];
}) {
const commands: EngineExternalCommand[] = [];
const sessionEngine = createAgentSessionEngine({
clock: { nowUnixMs: () => 1 },
commandPort: createTestEngineCommandPort({
execute: (command) => {
commands.push(command);
return new Promise<never>(() => {});
}
}),
identity: { origin: "test", workspaceId: "workspace-1" },
scheduler: { schedule: () => ({ cancel() {} }) }
});
const activateSession = vi.spyOn(sessionEngine, "activateSession");
const target = createLocalAgentGUIAgentTarget("codex");
const dataRef: { current: AgentGUINodeData } = {
current: {
agentTargetId: target.agentTargetId,
lastActiveAgentSessionId: input.activeConversation?.id ?? null,
provider: "codex" as const
}
};
const targetData: AgentGUIComposerTargetData = {
agentTargetId: target.agentTargetId ?? null,
data: dataRef.current,
provider: "codex",
targetId: target.targetId
};
const activeConversationIdRef = {
current: input.activeConversation?.id ?? null
};
const isComposerHomeRef = { current: input.activeConversation === null };
const selectedProjectPathRef = {
current: input.initialHomeProjectPath
};
const draftByScopeKeyRef = {
current: {} as Record<string, AgentComposerDraft>
};
const submittedDraftSnapshotsRef = { current: {} };
const agentActivityRuntime = {} as AgentGUIRuntime;
const setDraftByScopeKey = vi.fn();
const persistActiveConversation = vi.fn();
const conversationsRef = {
current: input.activeConversation ? [input.activeConversation] : []
};
const conversationListQuery = {
provider: "codex" as const,
sessionOrigin: "local",
userId: "user-1",
workspaceId: "workspace-1"
};
const { result } = renderHook(() => {
const activation = useAgentGUIActivation({
engine: sessionEngine,
getErrorMessage: (error) => String(error),
workspaceId: "workspace-1"
});
const { createConversation } = useAgentGUIConversationHome({
activeConversationId: activeConversationIdRef.current,
activeConversationIdRef,
activePendingActivation: null,
agentActivityRuntime,
composerAppendRequest: null,
composerTargetDataFromProviderTarget: () => targetData,
conversationFilterRef: { current: { kind: "all" } },
currentProvider: "codex",
dataRef,
defaultAgentTargetId: target.agentTargetId ?? null,
draftByScopeKeyRef,
handledComposerAppendSequenceRef: { current: null },
handledPrefillPromptSequenceRef: { current: null },
isComposerHomeRef,
isExplicitAgentGUIAgentTarget: () => true,
loadDraftComposerOptions: vi.fn(),
normalizedExplicitProviderTargets: [target],
normalizedProviderTargets: [target],
onDataChangeRef: {
current: (updater) => {
dataRef.current = updater(dataRef.current);
}
},
persistActiveConversation,
prefillPromptRequest: null,
reportActiveConversationCleared: vi.fn(),
selectedComposerTargetDataRef: { current: targetData },
selectedProjectPathRef,
setActiveConversationId: vi.fn(),
setConversationFilter: vi.fn(),
setDetailError: vi.fn(),
setDraftByScopeKey,
setHomeComposerTargetOverride: vi.fn(),
setIntent: vi.fn(),
setIsComposerHome: vi.fn(),
setIsLoadingMessages: vi.fn(),
setSelectedProjectPath: vi.fn(),
shouldUseStaticProviderTargets: true,
submitPrefillPrompt: vi.fn(),
unactivate: async () => undefined,
workspaceId: "workspace-1"
});
const startConversation = useAgentGUINewConversationActivation({
activation,
activeConversationIdRef,
activeSessionState: null,
agentActivityRuntime,
agentTargetsProvidedRef: { current: true },
conversationListQuery,
conversationsRef,
codexSaverModeEntryEnabled: input.codexSaverModeEntryEnabled ?? true,
rtkSaverModeEntryEnabled: input.rtkSaverModeEntryEnabled ?? true,
currentUserId: "user-1",
data: dataRef.current,
defaultReasoningEffort: "high",
draftByScopeKeyRef,
draftSettingsBySessionIdRef: {
current: input.rememberedSettings
? {
[nodeDefaultDraftKey("codex", target.agentTargetId)]:
input.rememberedSettings
}
: {}
},
getCachedComposerOptions: () => ({
behavior: {
collapseModelOptionsToLatest: false,
modelOptionsAuthoritative: false,
planModeExclusiveWithPermissionMode: false,
prewarmDraftSession: false,
refreshModelOptionsAfterSettings: false
},
capabilities: null,
codexSaverModeSupported: true,
rtkSaverModeSupported: true,
effectiveSettings: input.authoritativeSettings,
loadedAtUnixMs: 1,
models: [],
provider: "codex",
reasoningEfforts: [],
skills: [],
speeds: []
}),
isComposerHomeRef,
isConversationStale: () => false,
isCreatingConversationRef: { current: false },
isCurrentConversation: () => false,
onDataChangeRef: {
current: (updater) => {
dataRef.current = updater(dataRef.current);
}
},
requestRailReveal: vi.fn(),
selectedAgentTargetIsExplicitRef: { current: true },
selectedAgentTargetRef: { current: target },
selectedComposerTargetDataRef: { current: targetData },
selectedProjectPathRef,
sessionEngine,
setActiveConversationId: vi.fn(),
setDetailError: vi.fn(),
setIntent: vi.fn(),
setIsComposerHome: vi.fn(),
setIsLoadingMessages: vi.fn(),
submittedDraftSnapshotsRef,
tuttiModeDraftKey: "node-default:codex:local:codex",
userProjectsRef: { current: input.userProjects },
workspaceId: "workspace-1"
});
const { submitPrompt } = useAgentGUISubmitInteractionActions({
activation,
activeConversationId: activeConversationIdRef.current,
activeConversationIdRef,
activeEngineActiveTurn: null,
activeEnginePendingInteractions: [],
agentActivityRuntime,
conversationListQuery,
conversationsRef,
dataRef,
draftByScopeKeyRef,
executePromptRef: { current: vi.fn() },
goalControlSupported: true,
isComposerHomeRef,
isCurrentConversation: () => false,
isRespondingToInteraction: false,
isSessionMarkedNonResumable: () => false,
persistActiveConversation: vi.fn(),
planActionsRef: {
current: {
feedback: vi.fn(),
implement: vi.fn(),
skip: vi.fn()
}
},
promptImagesSupported: true,
sessionEngine,
setActiveConversationId: vi.fn(),
setDetailError: vi.fn(),
setDraftByScopeKey,
setGoalClearNoticeSequence: vi.fn(),
setIntent: vi.fn(),
startConversation,
submitPromptRef: { current: vi.fn() },
submittedDraftSnapshotsRef,
transientConversation: null,
workspaceId: "workspace-1"
});
return { createConversation, submitPrompt };
});
return {
activateSession,
requestNewConversation() {
requestAgentGUINewConversation({
activeConversationId: activeConversationIdRef.current,
conversations: conversationsRef.current,
createConversation: result.current.createConversation,
transientConversation: null,
userProjects: input.userProjects
});
},
persistActiveConversation,
submitPrompt: result.current.submitPrompt,
async waitForActivation() {
let activation: EngineExternalCommand | undefined;
await waitFor(() => {
const matches = commands.filter(
(command) => command.type === "session/activate"
);
expect(matches).toHaveLength(1);
activation = matches[0];
});
return activation as EngineExternalCommand & {
cwd: string;
railPlacement: AgentActivityRailPlacement;
};
}
};
}
function conversationSummary(input: {
cwd: string;
railSectionKey: string;
}): AgentGUIConversationSummary {
return {
id: "session-current",
provider: "codex",
title: "Current conversation",
status: "ready",
cwd: input.cwd,
railSectionKey: input.railSectionKey,
updatedAtUnixMs: 1
};
}