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
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import { renderHook } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { AgentGUINodeData } from "../../../types";
|
|
import type { AgentGUIConversationSummary } from "../model/agentGuiConversationModel";
|
|
import { useAgentGUIConversationPresentation } from "./useAgentGUIConversationPresentation";
|
|
|
|
type PresentationInput = Parameters<
|
|
typeof useAgentGUIConversationPresentation
|
|
>[0];
|
|
|
|
describe("useAgentGUIConversationPresentation", () => {
|
|
it("does not project a pending submit into Conversation working status", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
const pendingInput = {
|
|
...input,
|
|
activeLatestPendingSubmitTurnId: "turn-pending",
|
|
hasUnconfirmedSubmit: true,
|
|
isCreatingConversation: true,
|
|
isSubmitting: true
|
|
};
|
|
const rendered = renderHook(
|
|
({ value }: { value: PresentationInput }) =>
|
|
useAgentGUIConversationPresentation(value),
|
|
{ initialProps: { value: pendingInput } }
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.status).toBe("ready");
|
|
|
|
rendered.rerender({
|
|
value: { ...pendingInput, conversations: [] }
|
|
});
|
|
expect(rendered.result.current.activeConversation?.status).toBe("ready");
|
|
});
|
|
|
|
it("projects canonical activity status into Conversation status", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(
|
|
({ value }: { value: PresentationInput }) =>
|
|
useAgentGUIConversationPresentation(value),
|
|
{
|
|
initialProps: {
|
|
value: {
|
|
...input,
|
|
activityDisplayStatuses: new Map([
|
|
[conversation.id, "working" as const]
|
|
])
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.status).toBe("working");
|
|
});
|
|
|
|
it("lets canonical idle clear stale Conversation working status", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput({ ...conversation, status: "working" });
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
activityDisplayStatuses: new Map([[conversation.id, "idle" as const]])
|
|
})
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.status).toBe("ready");
|
|
expect(rendered.result.current.visibleConversations[0]?.status).toBe(
|
|
"ready"
|
|
);
|
|
});
|
|
|
|
it("does not project the provider name as a fallback conversation title", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
conversations: []
|
|
})
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation).toEqual(
|
|
expect.objectContaining({
|
|
provider: "codex",
|
|
title: "",
|
|
titleFallback: "untitled-conversation"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("does not infer fallback conversation project identity from workspacePath", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
conversations: [],
|
|
userProjects: [
|
|
{
|
|
id: "workspace-project",
|
|
label: "Workspace",
|
|
path: "/workspace",
|
|
pinnedAtUnixMs: 0,
|
|
sectionKey: "project:/workspace"
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.project).toBeNull();
|
|
});
|
|
|
|
it("reuses visible and active conversation references for render-equal input", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(
|
|
({ value }: { value: PresentationInput }) =>
|
|
useAgentGUIConversationPresentation(value),
|
|
{ initialProps: { value: input } }
|
|
);
|
|
const previous = rendered.result.current;
|
|
|
|
rendered.rerender({
|
|
value: {
|
|
...input,
|
|
activityDisplayStatuses: new Map(),
|
|
conversations: [{ ...conversation }]
|
|
}
|
|
});
|
|
|
|
expect(rendered.result.current.visibleConversations).toBe(
|
|
previous.visibleConversations
|
|
);
|
|
expect(rendered.result.current.activeConversation).toBe(
|
|
previous.activeConversation
|
|
);
|
|
});
|
|
|
|
it("updates active semantic metadata without invalidating the rail list", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(
|
|
({ value }: { value: PresentationInput }) =>
|
|
useAgentGUIConversationPresentation(value),
|
|
{ initialProps: { value: input } }
|
|
);
|
|
const previous = rendered.result.current;
|
|
|
|
rendered.rerender({
|
|
value: {
|
|
...input,
|
|
conversations: [{ ...conversation, resumable: false }]
|
|
}
|
|
});
|
|
|
|
expect(rendered.result.current.visibleConversations).toBe(
|
|
previous.visibleConversations
|
|
);
|
|
expect(rendered.result.current.activeConversation).not.toBe(
|
|
previous.activeConversation
|
|
);
|
|
expect(rendered.result.current.activeConversation?.resumable).toBe(false);
|
|
});
|
|
|
|
it("preserves the active agent target identity for open-provider fallback conversations", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput({
|
|
...conversation,
|
|
agentTargetId: "extension:example",
|
|
provider: "acp:example"
|
|
});
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
conversations: [],
|
|
data: {
|
|
...input.data,
|
|
agentTargetId: "extension:example",
|
|
provider: "acp:example"
|
|
},
|
|
dataRef: {
|
|
current: {
|
|
...input.dataRef.current,
|
|
agentTargetId: "extension:example",
|
|
provider: "acp:example"
|
|
}
|
|
},
|
|
normalizedProviderTargets: [
|
|
{
|
|
agentTargetId: "extension:example",
|
|
label: "Example Agent",
|
|
provider: "acp:example",
|
|
ref: {
|
|
kind: "agent_extension",
|
|
provider: "acp:example"
|
|
},
|
|
targetId: "extension:example"
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.title).toBe("");
|
|
expect(rendered.result.current.activeConversation?.titleFallback).toBe(
|
|
"untitled-conversation"
|
|
);
|
|
expect(rendered.result.current.activeConversation?.agentTargetId).toBe(
|
|
"extension:example"
|
|
);
|
|
});
|
|
|
|
it("preserves a missing explicit target instead of selecting a provider sibling", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput({
|
|
...conversation,
|
|
agentTargetId: "extension:missing",
|
|
provider: "acp:example"
|
|
});
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
conversations: [],
|
|
data: {
|
|
...input.data,
|
|
agentTargetId: "extension:missing",
|
|
provider: "acp:example"
|
|
},
|
|
dataRef: {
|
|
current: {
|
|
...input.dataRef.current,
|
|
agentTargetId: "extension:missing",
|
|
provider: "acp:example"
|
|
}
|
|
},
|
|
normalizedProviderTargets: [
|
|
{
|
|
agentTargetId: "extension:sibling",
|
|
label: "Sibling Agent",
|
|
provider: "acp:example",
|
|
ref: {
|
|
kind: "agent_extension",
|
|
provider: "acp:example"
|
|
},
|
|
targetId: "extension:sibling"
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.agentTargetId).toBe(
|
|
"extension:missing"
|
|
);
|
|
});
|
|
|
|
it("backfills target memory when canonical session metadata arrives", () => {
|
|
const conversation = createConversation();
|
|
const input = createInput(conversation);
|
|
let data: AgentGUINodeData = input.data;
|
|
|
|
input.dataRef = { current: data };
|
|
input.normalizedExplicitProviderTargets = [
|
|
{
|
|
agentTargetId: "target-1",
|
|
label: "Codex",
|
|
provider: "codex",
|
|
ref: { kind: "local", provider: "codex" },
|
|
targetId: "target-1"
|
|
}
|
|
];
|
|
input.normalizedProviderTargets = input.normalizedExplicitProviderTargets;
|
|
input.onDataChangeRef = {
|
|
current: (updater) => {
|
|
data = updater(data);
|
|
input.dataRef.current = data;
|
|
}
|
|
};
|
|
|
|
renderHook(() => useAgentGUIConversationPresentation(input));
|
|
|
|
expect(data.lastActiveAgentSessionIdByAgentTargetId).toEqual({
|
|
"target-1": "session-1"
|
|
});
|
|
});
|
|
|
|
it("keeps a hidden transient conversation out of the rail while presenting its identity", () => {
|
|
const conversation = createConversation();
|
|
const hiddenTransient: AgentGUIConversationSummary = {
|
|
...createConversation(),
|
|
hiddenFromRail: true,
|
|
id: "hidden-delegate-1",
|
|
title: "Delegated task"
|
|
};
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
activeConversationId: hiddenTransient.id,
|
|
transientConversation: hiddenTransient
|
|
})
|
|
);
|
|
|
|
expect(
|
|
rendered.result.current.visibleConversations.map((entry) => entry.id)
|
|
).toEqual([conversation.id]);
|
|
expect(rendered.result.current.activeConversation).toEqual(
|
|
expect.objectContaining({
|
|
id: "hidden-delegate-1",
|
|
provider: "codex",
|
|
title: "Delegated task"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("projects exact activity for a hidden transient conversation", () => {
|
|
const conversation = createConversation();
|
|
const hiddenTransient: AgentGUIConversationSummary = {
|
|
...createConversation(),
|
|
hiddenFromRail: true,
|
|
id: "hidden-delegate-1",
|
|
status: "working",
|
|
title: "Delegated task"
|
|
};
|
|
const input = createInput(conversation);
|
|
const rendered = renderHook(() =>
|
|
useAgentGUIConversationPresentation({
|
|
...input,
|
|
activeConversationId: hiddenTransient.id,
|
|
activityDisplayStatuses: new Map([
|
|
[hiddenTransient.id, "idle" as const]
|
|
]),
|
|
transientConversation: hiddenTransient
|
|
})
|
|
);
|
|
|
|
expect(rendered.result.current.activeConversation?.status).toBe("ready");
|
|
expect(
|
|
rendered.result.current.visibleConversations.some(
|
|
(entry) => entry.id === hiddenTransient.id
|
|
)
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
function createInput(
|
|
conversation: AgentGUIConversationSummary
|
|
): PresentationInput {
|
|
const data = {
|
|
lastActiveAgentSessionId: conversation.id,
|
|
provider: conversation.provider
|
|
};
|
|
return {
|
|
activeConversationId: conversation.id,
|
|
activeLatestPendingSubmitTurnId: null,
|
|
activityDisplayStatuses: new Map(),
|
|
agentTargetsLoading: false,
|
|
conversations: [conversation],
|
|
currentUserId: "user-1",
|
|
data,
|
|
dataRef: { current: data },
|
|
defaultAgentTargetId: null,
|
|
draftByScopeKey: {},
|
|
hasUnconfirmedSubmit: false,
|
|
isCreatingConversation: false,
|
|
isSubmitting: true,
|
|
normalizedExplicitProviderTargets: [],
|
|
normalizedProviderTargets: [],
|
|
onDataChangeRef: { current: vi.fn() },
|
|
shouldUseStaticProviderTargets: false,
|
|
transientConversation: null,
|
|
userProjects: [],
|
|
workspacePath: "/workspace"
|
|
};
|
|
}
|
|
|
|
function createConversation(): AgentGUIConversationSummary {
|
|
return {
|
|
agentTargetId: "target-1",
|
|
cwd: "/workspace",
|
|
id: "session-1",
|
|
provider: "codex",
|
|
status: "ready",
|
|
title: "streaming session",
|
|
titleFallback: null,
|
|
updatedAtUnixMs: 1,
|
|
userId: "user-1"
|
|
};
|
|
}
|