merge(frontend): fix #371 — nouvelle conversation par défaut à l'ouverture et au lancement d'un agent (QA verte)
Le lancement ordinaire d'un agent démarre une nouvelle conversation : freshStart: true sans dérivation du conversationId persisté sur la leaf, freshOnOpen à l'ouverture de cellule, réattach live préservé si session structurée vivante ; l'id persisté devient un pivot runtime/historique au service du resume explicite uniquement — #371 (QA verte: typecheck, 144/144 ciblés, 350/350 agents+layout ; f3476982 ; aucun push).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -240,8 +240,8 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
// backend select a chat session, whose reply stream can never feed the
|
// backend select a chat session, whose reply stream can never feed the
|
||||||
// xterm output channel and leaves the cell looking blank.
|
// xterm output channel and leaves the cell looking blank.
|
||||||
cellKind: "pty",
|
cellKind: "pty",
|
||||||
// Resume id: the leaf's persisted conversation id, when any (T4b). The
|
// Explicit resume id, when the caller intentionally selected one. An
|
||||||
// backend resumes it; absent ⇒ a fresh cell that may get a new id.
|
// ordinary launch never derives this value from the hosting leaf (#371).
|
||||||
conversationId: options.conversationId ?? null,
|
conversationId: options.conversationId ?? null,
|
||||||
freshStart: options.freshStart ?? false,
|
freshStart: options.freshStart ?? false,
|
||||||
// Hosting cell: lets the backend enforce one-live-session-per-agent.
|
// Hosting cell: lets the backend enforce one-live-session-per-agent.
|
||||||
@ -255,8 +255,8 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const base = makeTerminalHandle(res.sessionId, channel);
|
const base = makeTerminalHandle(res.sessionId, channel);
|
||||||
// Surface the id assigned by this launch (so the caller persists it on the
|
// Surface the id assigned by this launch so the caller can persist it as a
|
||||||
// leaf and resumes next time).
|
// runtime/history pivot without making it the next default UI selection.
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...(res.assignedConversationId
|
...(res.assignedConversationId
|
||||||
|
|||||||
@ -1060,8 +1060,9 @@ export type PreferredView = "tui" | "chat";
|
|||||||
|
|
||||||
/** A terminal-hosting leaf cell. `session` is the hosted SessionId, if any.
|
/** A terminal-hosting leaf cell. `session` is the hosted SessionId, if any.
|
||||||
* `agent` is the agent id if an agent is pinned to this cell (absent = plain terminal).
|
* `agent` is the agent id if an agent is pinned to this cell (absent = plain terminal).
|
||||||
* `conversationId` is the persistent CLI conversation id (survives PTY close/reopen,
|
* `conversationId` is a persistent runtime/history pivot. It can support an
|
||||||
* lets the agent resume); omitted when absent (mirrors the backend `skip_serializing_if`).
|
* explicit resume, but is not the default active UI selection (#371); omitted
|
||||||
|
* when absent (mirrors the backend `skip_serializing_if`).
|
||||||
* `agentWasRunning` records whether the cell's agent process was running at close time;
|
* `agentWasRunning` records whether the cell's agent process was running at close time;
|
||||||
* omitted when `false`. */
|
* omitted when `false`. */
|
||||||
export interface LeafCell {
|
export interface LeafCell {
|
||||||
|
|||||||
@ -4839,6 +4839,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
cols: 80,
|
cols: 80,
|
||||||
conversationId: undefined,
|
conversationId: undefined,
|
||||||
|
freshStart: true,
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -6719,6 +6720,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
|
||||||
expect(agent.launchAgentChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
expect(agent.launchAgentChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
||||||
|
expect(agent.launchAgentChat.mock.calls[0]?.[2].freshStart).toBe(true);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
|
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
|
||||||
const past = await screen.findByRole("menuitem", { name: /Saved question/ });
|
const past = await screen.findByRole("menuitem", { name: /Saved question/ });
|
||||||
fireEvent.click(past);
|
fireEvent.click(past);
|
||||||
@ -6735,6 +6737,13 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
expect(await screen.findByText("Conversation poursuivie")).toBeTruthy();
|
expect(await screen.findByText("Conversation poursuivie")).toBeTruthy();
|
||||||
|
expect(agent.launchAgentChat).toHaveBeenNthCalledWith(2, "project-1", "agent-1", {
|
||||||
|
cwd: "/repo",
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
conversationId: "past-conversation",
|
||||||
|
nodeId: "node-1",
|
||||||
|
});
|
||||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||||
"resumed-session",
|
"resumed-session",
|
||||||
"Continue",
|
"Continue",
|
||||||
|
|||||||
@ -1069,8 +1069,8 @@ export function CustomAgentChatView({
|
|||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
// `freshOnOpen` is reserved for an explicit reset intent. Ordinary React
|
// A normal cell opening starts fresh when no live session exists. React
|
||||||
// remounts keep the durable conversation selected by the hosting cell.
|
// remounts with a live session still preserve that runtime continuity.
|
||||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
||||||
sessionId || !freshOnOpen ? conversationId : null,
|
sessionId || !freshOnOpen ? conversationId : null,
|
||||||
);
|
);
|
||||||
@ -2005,6 +2005,7 @@ export function CustomAgentChatView({
|
|||||||
options.conversationId === undefined
|
options.conversationId === undefined
|
||||||
? activeConversationIdRef.current ?? undefined
|
? activeConversationIdRef.current ?? undefined
|
||||||
: options.conversationId ?? undefined,
|
: options.conversationId ?? undefined,
|
||||||
|
...(options.conversationId === null ? { freshStart: true } : {}),
|
||||||
...(nodeId ? { nodeId } : {}),
|
...(nodeId ? { nodeId } : {}),
|
||||||
};
|
};
|
||||||
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", {
|
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", {
|
||||||
@ -2266,6 +2267,29 @@ export function CustomAgentChatView({
|
|||||||
setSavedConversationLoadState("idle");
|
setSavedConversationLoadState("idle");
|
||||||
setLiveSessionAttached(false);
|
setLiveSessionAttached(false);
|
||||||
if (freshOnOpen) {
|
if (freshOnOpen) {
|
||||||
|
// A missing layout session can be a transient remount while this
|
||||||
|
// agent is still active. Preserve that live continuity without
|
||||||
|
// treating the leaf's persisted conversation id as a UI choice.
|
||||||
|
const live = agent.listLiveAgents
|
||||||
|
? (await agent.listLiveAgents(projectId)).find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.agentId === agentId && candidate.kind === "structured",
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (live) {
|
||||||
|
const rebound =
|
||||||
|
nodeId && live.nodeId !== nodeId && agent.attachLiveAgent
|
||||||
|
? await agent.attachLiveAgent(projectId, agentId, nodeId)
|
||||||
|
: live;
|
||||||
|
const attachedSessionId = await reattachStructuredSession(rebound.sessionId, {
|
||||||
|
applyScrollback: true,
|
||||||
|
preserveViewOnEmptyScrollback: true,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
publishSessionId(attachedSessionId);
|
||||||
|
setResumedConversation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await recoverStructuredSession({
|
await recoverStructuredSession({
|
||||||
applyScrollback: false,
|
applyScrollback: false,
|
||||||
retryAttachNotFound: true,
|
retryAttachNotFound: true,
|
||||||
|
|||||||
@ -210,7 +210,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
|
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores durable messages after project A → B → A without starting a provider", async () => {
|
it("starts fresh on first project open, then preserves the live session across A → B → A", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const profileGateway = new MockProfileGateway();
|
const profileGateway = new MockProfileGateway();
|
||||||
@ -271,8 +271,11 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
const view = renderGrid(gateways, "p1");
|
const view = renderGrid(gateways, "p1");
|
||||||
|
|
||||||
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
|
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
|
||||||
expect(await screen.findByText("Question projet A")).toBeTruthy();
|
await waitFor(() => expect(launchChat).toHaveBeenCalledTimes(1));
|
||||||
expect(screen.getByText("Réponse projet A")).toBeTruthy();
|
expect(launchChat.mock.calls[0]?.[2]).toMatchObject({ freshStart: true });
|
||||||
|
expect(launchChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
||||||
|
expect(screen.queryByText("Question projet A")).toBeNull();
|
||||||
|
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
|
||||||
const persistedA = leaves(await layout.loadLayout("p1"))[0];
|
const persistedA = leaves(await layout.loadLayout("p1"))[0];
|
||||||
|
|
||||||
view.rerender(
|
view.rerender(
|
||||||
@ -293,13 +296,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
|
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
|
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
|
||||||
expect(await screen.findByText("Question projet A")).toBeTruthy();
|
expect(screen.queryByText("Question projet A")).toBeNull();
|
||||||
expect(screen.getByText("Réponse projet A")).toBeTruthy();
|
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
|
||||||
const restoredA = leaves(await layout.loadLayout("p1"))[0];
|
const restoredA = leaves(await layout.loadLayout("p1"))[0];
|
||||||
expect(restoredA.preferredView).toBe("chat");
|
expect(restoredA.preferredView).toBe("chat");
|
||||||
expect(restoredA.session).toBe(persistedA.session);
|
expect(restoredA.session).toBe(persistedA.session);
|
||||||
expect(restoredA.conversationId).toBe(persistedA.conversationId);
|
expect(restoredA.conversationId).toBe(persistedA.conversationId);
|
||||||
expect(launchChat).not.toHaveBeenCalled();
|
expect(launchChat).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
|
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
|
||||||
@ -367,6 +370,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
const leaf = leaves(tree)[0];
|
const leaf = leaves(tree)[0];
|
||||||
const requesterId = leaf.agent;
|
const requesterId = leaf.agent;
|
||||||
if (!requesterId) throw new Error("expected seeded requester");
|
if (!requesterId) throw new Error("expected seeded requester");
|
||||||
|
vi.spyOn(gateways.agent, "launchAgentChat").mockImplementation(
|
||||||
|
() => new Promise(() => {}),
|
||||||
|
);
|
||||||
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
|
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
|
||||||
workState._setProjectWorkState("p1", {
|
workState._setProjectWorkState("p1", {
|
||||||
agents: [
|
agents: [
|
||||||
@ -497,7 +503,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores a saved custom CLI conversation when returning without a live session (#366)", async () => {
|
it("starts a fresh custom CLI conversation instead of restoring the leaf pivot (#371)", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const profileGateway = new MockProfileGateway();
|
const profileGateway = new MockProfileGateway();
|
||||||
@ -545,14 +551,39 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
conversation,
|
conversation,
|
||||||
} as unknown as Gateways);
|
} as unknown as Gateways);
|
||||||
|
|
||||||
expect(await screen.findByText("known prompt")).toBeTruthy();
|
await waitFor(() => expect(launchChat).toHaveBeenCalledTimes(1));
|
||||||
expect(screen.getByText("known response")).toBeTruthy();
|
expect(launchChat.mock.calls[0]?.[2]).toMatchObject({ freshStart: true });
|
||||||
expect(screen.queryByText("Nouvelle conversation")).toBeNull();
|
expect(launchChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
||||||
expect(launchChat).not.toHaveBeenCalled();
|
expect(screen.queryByText("known prompt")).toBeNull();
|
||||||
expect(reattachChat).not.toHaveBeenCalled();
|
expect(screen.queryByText("known response")).toBeNull();
|
||||||
|
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
|
||||||
|
expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function));
|
||||||
const updated = await layout.loadLayout("p1");
|
const updated = await layout.loadLayout("p1");
|
||||||
expect(leaves(updated)[0].conversationId).toBe("conversation-known");
|
expect(leaves(updated)[0].conversationId).toBe("conversation-fresh");
|
||||||
expect(leaves(updated)[0].session).toBeNull();
|
expect(leaves(updated)[0].session).toBe("chat-fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launches a normal native agent fresh even when the leaf stores an old conversation (#371)", async () => {
|
||||||
|
const gateways = await seeded(ptyProfile);
|
||||||
|
const layout = gateways.layout as MockLayoutGateway;
|
||||||
|
const agent = gateways.agent as MockAgentGateway;
|
||||||
|
const leaf = leaves(await layout.loadLayout("p1"))[0];
|
||||||
|
await layout.mutateLayout("p1", {
|
||||||
|
type: "setCellConversation",
|
||||||
|
target: leaf.id,
|
||||||
|
conversationId: "old-conversation",
|
||||||
|
});
|
||||||
|
const launch = vi.spyOn(agent, "launchAgent");
|
||||||
|
|
||||||
|
renderGrid(gateways);
|
||||||
|
|
||||||
|
await waitFor(() => expect(launch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(launch.mock.calls[0]?.[2]).toMatchObject({
|
||||||
|
cwd: "/home/me/proj",
|
||||||
|
nodeId: leaf.id,
|
||||||
|
freshStart: true,
|
||||||
|
});
|
||||||
|
expect(launch.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
||||||
|
|||||||
@ -1297,7 +1297,6 @@ function LeafView({
|
|||||||
const doLaunch = async (
|
const doLaunch = async (
|
||||||
opts: OpenTerminalOptions,
|
opts: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
convId: string | undefined,
|
|
||||||
): Promise<TerminalHandle> => {
|
): Promise<TerminalHandle> => {
|
||||||
const background = backgroundLive(agentId!);
|
const background = backgroundLive(agentId!);
|
||||||
if (background?.sessionId && agentGateway!.attachLiveAgent) {
|
if (background?.sessionId && agentGateway!.attachLiveAgent) {
|
||||||
@ -1313,7 +1312,7 @@ function LeafView({
|
|||||||
.launchAgent(
|
.launchAgent(
|
||||||
projectId,
|
projectId,
|
||||||
agentId!,
|
agentId!,
|
||||||
{ ...opts, conversationId: convId, nodeId: id },
|
{ ...opts, freshStart: true, nodeId: id },
|
||||||
onData,
|
onData,
|
||||||
)
|
)
|
||||||
.catch(async (err: unknown) => {
|
.catch(async (err: unknown) => {
|
||||||
@ -1326,8 +1325,8 @@ function LeafView({
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
// First launch on a fresh cell mints a conversation id: persist it on the
|
// A normal cell launch always starts clean. Persist the assigned id only as
|
||||||
// leaf so the next open resumes (T4b — closes the persistence loop).
|
// a runtime/history pivot; it is not the next default UI selection (#371).
|
||||||
if (handle.assignedConversationId) {
|
if (handle.assignedConversationId) {
|
||||||
void vm.setCellConversation(id, handle.assignedConversationId);
|
void vm.setCellConversation(id, handle.assignedConversationId);
|
||||||
}
|
}
|
||||||
@ -1336,7 +1335,7 @@ function LeafView({
|
|||||||
|
|
||||||
const terminalOpener = agentGateway && agentId
|
const terminalOpener = agentGateway && agentId
|
||||||
? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> => {
|
? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> => {
|
||||||
return doLaunch(opts, onData, conversationId ?? undefined);
|
return doLaunch(opts, onData);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
// Agent cells re-attach through the agent gateway; plain cells fall back to
|
// Agent cells re-attach through the agent gateway; plain cells fall back to
|
||||||
@ -1704,6 +1703,7 @@ function LeafView({
|
|||||||
nodeId={id}
|
nodeId={id}
|
||||||
sessionId={session}
|
sessionId={session}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
|
freshOnOpen
|
||||||
composerHeightPx={customChatComposerHeight}
|
composerHeightPx={customChatComposerHeight}
|
||||||
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
|
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
|
||||||
onComposerHeightPxChange={setCustomChatComposerHeight}
|
onComposerHeightPxChange={setCustomChatComposerHeight}
|
||||||
|
|||||||
@ -1,21 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* T4b — closing the conversation-id persistence loop.
|
* T4b / #371 — conversation-id persistence without implicit UI resume.
|
||||||
*
|
*
|
||||||
* Three layers, mirroring the SetCellAgent coverage:
|
* Three layers, mirroring the SetCellAgent coverage:
|
||||||
* 1. The mock agent gateway assigns a fresh conversation id on a first launch
|
* 1. The mock agent gateway assigns a fresh conversation id on a first launch
|
||||||
* (cell had none) and resumes (no new id) when one is passed in.
|
* (cell had none) and resumes (no new id) when one is passed in.
|
||||||
* 2. `MockLayoutGateway` applies the `setCellConversation` op, persisting/clearing
|
* 2. `MockLayoutGateway` applies the `setCellConversation` op, persisting/clearing
|
||||||
* the id on the leaf.
|
* the id on the leaf.
|
||||||
* 3. The grid wiring: when an agent cell launches and the backend assigns an id,
|
* 3. The grid wiring persists an id assigned by a normal launch as a runtime /
|
||||||
* `setCellConversation` is emitted so the next open resumes; and a leaf that
|
* history pivot, but never passes a leaf's stored id into another ordinary
|
||||||
* already carries an id passes it back into `launchAgent`.
|
* launch. Only an explicit resume flow may do that.
|
||||||
*
|
|
||||||
* As elsewhere (TerminalView.test), xterm may bail under jsdom, so the render-level
|
|
||||||
* assertions only fire when the opener actually ran.
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({
|
||||||
|
Terminal: class {
|
||||||
|
loadAddon() {}
|
||||||
|
open() {}
|
||||||
|
onData() {
|
||||||
|
return { dispose() {} };
|
||||||
|
}
|
||||||
|
write() {}
|
||||||
|
dispose() {}
|
||||||
|
get cols() {
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
get rows() {
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
|
FitAddon: class {
|
||||||
|
fit() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||||
|
|
||||||
|
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
import type { Gateways, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
import type { Gateways, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||||
import {
|
import {
|
||||||
MockLayoutGateway,
|
MockLayoutGateway,
|
||||||
@ -132,19 +161,14 @@ describe("LayoutGrid — conversation-id loop wiring", () => {
|
|||||||
renderGrid(layout, stubAgent, "p1");
|
renderGrid(layout, stubAgent, "p1");
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||||
|
await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1));
|
||||||
// Only assert the loop when the opener actually ran (xterm may bail in jsdom).
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
if (launchAgent.mock.calls.length > 0) {
|
const fresh = await layout.loadLayout("p1");
|
||||||
const fresh = await layout.loadLayout("p1");
|
expect(leaves(fresh)[0].conversationId).toBe("assigned-conv-1");
|
||||||
expect(leaves(fresh)[0].conversationId).toBe("assigned-conv-1");
|
|
||||||
} else {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the leaf's existing conversation id into launchAgent (resume)", async () => {
|
it("does not pass the leaf's stored conversation id into an ordinary launch", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||||
@ -186,14 +210,9 @@ describe("LayoutGrid — conversation-id loop wiring", () => {
|
|||||||
renderGrid(layout, stubAgent, "p1");
|
renderGrid(layout, stubAgent, "p1");
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||||
|
await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1));
|
||||||
await waitFor(() => {
|
const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions;
|
||||||
if (launchAgent.mock.calls.length > 0) {
|
expect(opts.conversationId).toBeUndefined();
|
||||||
const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions;
|
expect(opts.freshStart).toBe(true);
|
||||||
expect(opts.conversationId).toBe("prior-conv");
|
|
||||||
} else {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -64,8 +64,9 @@ export interface LayoutViewModel {
|
|||||||
session: string,
|
session: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Records (or clears) the persistent CLI conversation id on a cell (T4b). Used
|
* Records (or clears) the persistent CLI conversation id on a cell (T4b).
|
||||||
* to persist the id assigned at first launch so the next open resumes it.
|
* This is a runtime/history pivot; it does not select that conversation for
|
||||||
|
* the next ordinary UI launch (#371).
|
||||||
*/
|
*/
|
||||||
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
||||||
/** Persists the preferred TUI or structured-chat surface for one agent cell. */
|
/** Persists the preferred TUI or structured-chat surface for one agent cell. */
|
||||||
|
|||||||
@ -357,10 +357,10 @@ export interface AgentGateway {
|
|||||||
*
|
*
|
||||||
* The returned handle carries an optional {@link TerminalHandle.assignedConversationId}:
|
* The returned handle carries an optional {@link TerminalHandle.assignedConversationId}:
|
||||||
* when the agent's profile assigns a fresh CLI conversation id on this first
|
* when the agent's profile assigns a fresh CLI conversation id on this first
|
||||||
* launch (the cell had none yet), it is surfaced here so the caller can persist
|
* launch, it is surfaced here so the caller can persist it on the hosting leaf
|
||||||
* it on the hosting leaf (`setCellConversation`) and resume on the next open.
|
* (`setCellConversation`) as a runtime/history pivot. Ordinary launches omit
|
||||||
* Pass the leaf's current `conversationId` via {@link OpenTerminalOptions} to
|
* that persisted id and start clean; only an explicit resume action passes a
|
||||||
* resume an existing conversation instead.
|
* `conversationId` via {@link OpenTerminalOptions}.
|
||||||
*/
|
*/
|
||||||
launchAgent(
|
launchAgent(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@ -568,10 +568,11 @@ export interface TerminalHandle {
|
|||||||
readonly sessionId: string;
|
readonly sessionId: string;
|
||||||
/**
|
/**
|
||||||
* Conversation id **assigned** by an agent launch when the profile minted a
|
* Conversation id **assigned** by an agent launch when the profile minted a
|
||||||
* fresh one (the cell had none yet). Present only on handles returned by
|
* fresh one. Present only on handles returned by
|
||||||
* {@link AgentGateway.launchAgent}; `undefined` for plain terminals, resumes,
|
* {@link AgentGateway.launchAgent}; `undefined` for plain terminals, resumes,
|
||||||
* or profiles without a session block. The caller persists it on the hosting
|
* or profiles without a session block. The caller may persist it on the
|
||||||
* leaf so the next open resumes instead of re-assigning.
|
* hosting leaf as a runtime/history pivot; persistence alone never requests
|
||||||
|
* an implicit UI resume (#371).
|
||||||
*/
|
*/
|
||||||
readonly assignedConversationId?: string;
|
readonly assignedConversationId?: string;
|
||||||
/** Sends bytes (xterm keystrokes) to the PTY. */
|
/** Sends bytes (xterm keystrokes) to the PTY. */
|
||||||
|
|||||||
Reference in New Issue
Block a user