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
483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { translate } from "../../../i18n/index";
|
|
import { useAgentGUIConversationMetadataActions } from "./useAgentGUIConversationMetadataActions";
|
|
|
|
const dispatchSessionForkThroughTurn = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@tutti-os/agent-activity-core", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("@tutti-os/agent-activity-core")>();
|
|
return { ...actual, dispatchSessionForkThroughTurn };
|
|
});
|
|
|
|
describe("useAgentGUIConversationMetadataActions project pin", () => {
|
|
it("delegates to the shared user-project store and diagnoses failures silently", async () => {
|
|
const pin = vi.fn(() => Promise.reject(new Error("pin failed")));
|
|
const logRuntimeDiagnostics = vi.fn();
|
|
const toastError = vi.fn();
|
|
const setUserProjectsSnapshot = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: {
|
|
debug: { logRuntimeDiagnostics },
|
|
toast: { error: toastError },
|
|
userProjects: { pin }
|
|
} as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: {} as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot,
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1"
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.toggleProjectPinned(" project-1 ", true);
|
|
});
|
|
|
|
expect(pin).toHaveBeenCalledWith({
|
|
pinned: true,
|
|
projectId: "project-1"
|
|
});
|
|
expect(logRuntimeDiagnostics).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
phase: "pin_user_project_failed",
|
|
pinned: true,
|
|
projectId: "project-1"
|
|
})
|
|
);
|
|
expect(setUserProjectsSnapshot).not.toHaveBeenCalled();
|
|
expect(toastError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useAgentGUIConversationMetadataActions project removal", () => {
|
|
it("waits for the authoritative removal before changing the local snapshot", async () => {
|
|
let finishRemoval!: () => void;
|
|
const remove = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
finishRemoval = resolve;
|
|
})
|
|
);
|
|
const setUserProjectsSnapshot = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: {
|
|
toast: { error: vi.fn() },
|
|
userProjects: { remove }
|
|
} as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: {} as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot,
|
|
userProjectsRef: {
|
|
current: [
|
|
{ id: "remove", path: "/workspace/remove" },
|
|
{ id: "keep", path: "/workspace/keep" }
|
|
]
|
|
} as never,
|
|
workspaceId: "workspace-1"
|
|
})
|
|
);
|
|
|
|
let removal!: Promise<boolean>;
|
|
act(() => {
|
|
removal = result.current.removeProject("/workspace/remove");
|
|
});
|
|
expect(setUserProjectsSnapshot).not.toHaveBeenCalled();
|
|
finishRemoval();
|
|
await expect(removal).resolves.toBe(true);
|
|
expect(setUserProjectsSnapshot).toHaveBeenCalledWith([
|
|
expect.objectContaining({ id: "keep" })
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("useAgentGUIConversationMetadataActions rename", () => {
|
|
it("waits for an optimistic new Session before using the Engine mutation", async () => {
|
|
let snapshot = {
|
|
pendingIntents: {
|
|
activationsByRequestId: {
|
|
"activation-1": {
|
|
agentSessionId: "new-session",
|
|
requestedAtUnixMs: 1,
|
|
status: "requested"
|
|
}
|
|
}
|
|
},
|
|
sessionLifecycle: { sessionsById: {} }
|
|
};
|
|
const listeners = new Set<() => void>();
|
|
const renameSession = vi.fn().mockResolvedValue({});
|
|
const sessionEngine = {
|
|
getSnapshot: () => snapshot,
|
|
renameSession,
|
|
subscribe: (listener: () => void) => {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}
|
|
};
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: { toast: { error: vi.fn() } } as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: sessionEngine as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1"
|
|
})
|
|
);
|
|
|
|
let renamePromise!: Promise<void>;
|
|
act(() => {
|
|
renamePromise = result.current.renameConversation(
|
|
"new-session",
|
|
"Renamed session"
|
|
);
|
|
});
|
|
expect(renameSession).not.toHaveBeenCalled();
|
|
|
|
snapshot = {
|
|
...snapshot,
|
|
sessionLifecycle: {
|
|
sessionsById: { "new-session": { agentSessionId: "new-session" } }
|
|
}
|
|
};
|
|
act(() => {
|
|
for (const listener of listeners) listener();
|
|
});
|
|
await renamePromise;
|
|
|
|
expect(renameSession).toHaveBeenCalledWith({
|
|
agentSessionId: "new-session",
|
|
title: "Renamed session"
|
|
});
|
|
});
|
|
|
|
it("delegates an existing Session rename to the Engine", async () => {
|
|
const renameSession = vi.fn().mockResolvedValue({});
|
|
const sessionEngine = {
|
|
getSnapshot: () => ({
|
|
pendingIntents: { activationsByRequestId: {} },
|
|
sessionLifecycle: {
|
|
sessionsById: {
|
|
"existing-session": { agentSessionId: "existing-session" }
|
|
}
|
|
}
|
|
}),
|
|
renameSession,
|
|
subscribe: vi.fn(() => vi.fn())
|
|
};
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: { toast: { error: vi.fn() } } as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: sessionEngine as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1"
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.renameConversation(
|
|
"existing-session",
|
|
"Renamed session"
|
|
);
|
|
});
|
|
|
|
expect(renameSession).toHaveBeenCalledWith({
|
|
agentSessionId: "existing-session",
|
|
title: "Renamed session"
|
|
});
|
|
});
|
|
|
|
it("reports when activation fails before the canonical Session appears", async () => {
|
|
let snapshot = {
|
|
pendingIntents: {
|
|
activationsByRequestId: {
|
|
"activation-1": {
|
|
agentSessionId: "failed-session",
|
|
requestedAtUnixMs: 1,
|
|
status: "requested"
|
|
}
|
|
}
|
|
},
|
|
sessionLifecycle: { sessionsById: {} }
|
|
};
|
|
const listeners = new Set<() => void>();
|
|
const renameSession = vi.fn().mockResolvedValue({});
|
|
const toastError = vi.fn();
|
|
const sessionEngine = {
|
|
getSnapshot: () => snapshot,
|
|
renameSession,
|
|
subscribe: (listener: () => void) => {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}
|
|
};
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: { toast: { error: toastError } } as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: sessionEngine as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1"
|
|
})
|
|
);
|
|
|
|
let renamePromise!: Promise<void>;
|
|
act(() => {
|
|
renamePromise = result.current.renameConversation(
|
|
"failed-session",
|
|
"Renamed session"
|
|
);
|
|
});
|
|
|
|
snapshot = {
|
|
...snapshot,
|
|
pendingIntents: {
|
|
activationsByRequestId: {
|
|
"activation-1": {
|
|
agentSessionId: "failed-session",
|
|
requestedAtUnixMs: 1,
|
|
status: "failed"
|
|
}
|
|
}
|
|
}
|
|
};
|
|
act(() => {
|
|
for (const listener of listeners) listener();
|
|
});
|
|
|
|
await expect(renamePromise).rejects.toThrow(
|
|
translate("agentHost.agentGui.sessionActionUnavailable")
|
|
);
|
|
expect(renameSession).not.toHaveBeenCalled();
|
|
expect(toastError).toHaveBeenCalledWith(
|
|
translate("agentHost.agentGui.sessionActionUnavailable")
|
|
);
|
|
});
|
|
|
|
it("times out while activation remains pending instead of holding rename forever", async () => {
|
|
const timeoutController = new AbortController();
|
|
const timeoutSpy = vi
|
|
.spyOn(AbortSignal, "timeout")
|
|
.mockReturnValue(timeoutController.signal);
|
|
try {
|
|
const renameSession = vi.fn().mockResolvedValue({});
|
|
const sessionEngine = {
|
|
getSnapshot: () => ({
|
|
pendingIntents: {
|
|
activationsByRequestId: {
|
|
"activation-1": {
|
|
agentSessionId: "stalled-session",
|
|
requestedAtUnixMs: 1,
|
|
status: "requested"
|
|
}
|
|
}
|
|
},
|
|
sessionLifecycle: { sessionsById: {} }
|
|
}),
|
|
renameSession,
|
|
subscribe: vi.fn(() => vi.fn())
|
|
};
|
|
const toastError = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: { toast: { error: toastError } } as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: sessionEngine as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1"
|
|
})
|
|
);
|
|
|
|
let renamePromise!: Promise<void>;
|
|
act(() => {
|
|
renamePromise = result.current.renameConversation(
|
|
"stalled-session",
|
|
"Renamed session"
|
|
);
|
|
});
|
|
const renameRejection = expect(renamePromise).rejects.toThrow(
|
|
translate("agentHost.agentGui.sessionActionUnavailable")
|
|
);
|
|
act(() => {
|
|
timeoutController.abort();
|
|
});
|
|
|
|
await renameRejection;
|
|
expect(renameSession).not.toHaveBeenCalled();
|
|
expect(toastError).toHaveBeenCalledWith(
|
|
translate("agentHost.agentGui.sessionActionUnavailable")
|
|
);
|
|
} finally {
|
|
timeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("useAgentGUIConversationMetadataActions fork identity", () => {
|
|
it("leaves identity reuse to the Engine facade and selects its authoritative target", async () => {
|
|
const unsupported = Object.assign(new Error("unsupported"), {
|
|
code: "agent_session_fork_unsupported"
|
|
});
|
|
const selectConversation = vi.fn();
|
|
dispatchSessionForkThroughTurn
|
|
.mockRejectedValueOnce(unsupported)
|
|
.mockResolvedValueOnce({
|
|
status: "succeeded",
|
|
targetAgentSessionId: "session-authoritative-child"
|
|
});
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: {} as never,
|
|
agentHostApi: {
|
|
debug: { logRuntimeDiagnostics: vi.fn() },
|
|
toast: { error: vi.fn() }
|
|
} as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: {} as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1",
|
|
selectConversation
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.forkConversationThroughTurn(
|
|
"session-source",
|
|
"turn-1"
|
|
);
|
|
await result.current.forkConversationThroughTurn(
|
|
"session-source",
|
|
"turn-1"
|
|
);
|
|
});
|
|
|
|
expect(dispatchSessionForkThroughTurn).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.anything(),
|
|
{
|
|
sourceAgentSessionId: "session-source",
|
|
turnId: "turn-1",
|
|
workspaceId: "workspace-1"
|
|
}
|
|
);
|
|
expect(dispatchSessionForkThroughTurn).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.anything(),
|
|
{
|
|
sourceAgentSessionId: "session-source",
|
|
turnId: "turn-1",
|
|
workspaceId: "workspace-1"
|
|
}
|
|
);
|
|
expect(selectConversation).toHaveBeenCalledWith(
|
|
"session-authoritative-child"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("useAgentGUIConversationMetadataActions fork lineage navigation", () => {
|
|
it("resolves the exact source Session before selecting it", async () => {
|
|
const getSession = vi.fn().mockResolvedValue({
|
|
agentSessionId: "session-source"
|
|
});
|
|
const selectConversation = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: { getSession } as never,
|
|
agentHostApi: {
|
|
toast: { error: vi.fn() }
|
|
} as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: {} as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1",
|
|
selectConversation
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.openForkSourceConversation(" session-source ");
|
|
});
|
|
|
|
expect(getSession).toHaveBeenCalledWith("workspace-1", "session-source");
|
|
expect(selectConversation).toHaveBeenCalledWith("session-source");
|
|
});
|
|
|
|
it("reports a missing source only after the lookup fails", async () => {
|
|
const missingSource = {
|
|
code: "workspace_not_found",
|
|
reason: "workspace_agent_session_not_found",
|
|
statusCode: 404
|
|
};
|
|
const getSession = vi.fn().mockRejectedValue(missingSource);
|
|
const selectConversation = vi.fn();
|
|
const toastError = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIConversationMetadataActions({
|
|
agentActivityRuntime: { getSession } as never,
|
|
agentHostApi: {
|
|
toast: { error: toastError }
|
|
} as never,
|
|
currentUserId: "user-1",
|
|
dataRef: { current: { provider: "codex" } } as never,
|
|
sessionEngine: {} as never,
|
|
setDetailError: vi.fn(),
|
|
setListError: vi.fn(),
|
|
setUserProjectsSnapshot: vi.fn(),
|
|
userProjectsRef: { current: [] },
|
|
workspaceId: "workspace-1",
|
|
selectConversation
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.openForkSourceConversation("missing-source-session");
|
|
});
|
|
|
|
expect(selectConversation).not.toHaveBeenCalled();
|
|
expect(toastError).toHaveBeenCalledWith(
|
|
translate("agentHost.agentGui.sourceConversationNotFound")
|
|
);
|
|
});
|
|
});
|