feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -17,6 +17,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import type { Agent } from "@/domain";
|
||||
import type {
|
||||
AgentGateway,
|
||||
ConversationDetails,
|
||||
CreateAgentInput,
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
@ -30,6 +31,8 @@ interface LaunchAgentResponse {
|
||||
cwd: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
||||
assignedConversationId?: string;
|
||||
}
|
||||
|
||||
export class TauriAgentGateway implements AgentGateway {
|
||||
@ -85,11 +88,19 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
agentId,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
// Resume id: the leaf's persisted conversation id, when any (T4b). The
|
||||
// backend resumes it; absent ⇒ a fresh cell that may get a new id.
|
||||
conversationId: options.conversationId ?? null,
|
||||
},
|
||||
onOutput: channel,
|
||||
});
|
||||
|
||||
return makeTerminalHandle(res.sessionId, channel);
|
||||
const handle = makeTerminalHandle(res.sessionId, channel);
|
||||
// Surface the id assigned by this launch so the caller persists it on the
|
||||
// leaf (`setCellConversation`) and resumes next time.
|
||||
return res.assignedConversationId
|
||||
? { ...handle, assignedConversationId: res.assignedConversationId }
|
||||
: handle;
|
||||
}
|
||||
|
||||
async reattach(
|
||||
@ -111,4 +122,16 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
scrollback: Uint8Array.from(res.scrollback),
|
||||
};
|
||||
}
|
||||
|
||||
inspectConversation(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationDetails> {
|
||||
// `inspect_conversation` takes a single `request` DTO; absent fields are
|
||||
// omitted from the response (best-effort), so an empty object is valid.
|
||||
return invoke<ConversationDetails>("inspect_conversation", {
|
||||
request: { projectId, agentId, conversationId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentGateway,
|
||||
ConversationDetails,
|
||||
CreateAgentInput,
|
||||
CreateSkillInput,
|
||||
CreateTemplateInput,
|
||||
@ -323,7 +324,17 @@ export class MockAgentGateway implements AgentGateway {
|
||||
queueMicrotask(() =>
|
||||
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||
);
|
||||
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
||||
const handle = makeMockHandle(session, () => this.sessions.delete(sessionId));
|
||||
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
|
||||
// newly-minted id surfaced on the handle so the caller persists it; a cell
|
||||
// that already carries an id resumes it and nothing new is assigned.
|
||||
if (!options.conversationId) {
|
||||
return {
|
||||
...handle,
|
||||
assignedConversationId: `mock-conversation-${sessionId}`,
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
async reattach(
|
||||
@ -344,6 +355,31 @@ export class MockAgentGateway implements AgentGateway {
|
||||
scrollback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort conversation details, keyed by conversation id (T7). Empty by
|
||||
* default (degraded mode); a test seeds enriched details via
|
||||
* {@link _setConversationDetails}.
|
||||
*/
|
||||
private conversationDetails = new Map<string, ConversationDetails>();
|
||||
|
||||
/** Seeds the (best-effort) details returned for a given conversation id. */
|
||||
_setConversationDetails(
|
||||
conversationId: string,
|
||||
details: ConversationDetails,
|
||||
): void {
|
||||
this.conversationDetails.set(conversationId, details);
|
||||
}
|
||||
|
||||
async inspectConversation(
|
||||
_projectId: string,
|
||||
_agentId: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationDetails> {
|
||||
// Best-effort: an unknown conversation yields empty details (degraded mode),
|
||||
// never an error — mirroring the backend contract.
|
||||
return structuredClone(this.conversationDetails.get(conversationId) ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -421,6 +457,16 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
scrollback,
|
||||
};
|
||||
}
|
||||
|
||||
async closeTerminal(sessionId: string): Promise<void> {
|
||||
// Best-effort / idempotent: kill the PTY by id (mirrors the handle's close)
|
||||
// so a later reattach to it fails. No-op if the session is already gone.
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.closed = true;
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MockProjectGateway implements ProjectGateway {
|
||||
|
||||
@ -168,4 +168,21 @@ describe("MockTerminalGateway", () => {
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("closeTerminal(sessionId) kills the PTY so a later reattach fails (Bug #3)", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/c", rows: 24, cols: 80 },
|
||||
() => {},
|
||||
);
|
||||
await gw.closeTerminal(handle.sessionId);
|
||||
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("closeTerminal on an unknown session is a no-op (best-effort)", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
await expect(gw.closeTerminal("does-not-exist")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
109
frontend/src/adapters/terminal.test.ts
Normal file
109
frontend/src/adapters/terminal.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Contract / regression tests for the Tauri terminal adapter.
|
||||
*
|
||||
* The headline test guards the keystroke-ordering bug: `write` must serialise
|
||||
* per handle so bytes reach the backend in call order even when the underlying
|
||||
* `invoke`s resolve out of order (Tauri's IPC gives no ordering guarantee for
|
||||
* concurrent calls). Without serialisation, fast typing/pasting garbles the CLI
|
||||
* input.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invoke(...args),
|
||||
Channel: class {
|
||||
onmessage: ((c: number[]) => void) | null = null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
|
||||
import { makeTerminalHandle } from "./terminal";
|
||||
|
||||
const dec = new TextDecoder();
|
||||
|
||||
function handle() {
|
||||
return makeTerminalHandle("sess-1", new Channel<number[]>());
|
||||
}
|
||||
|
||||
describe("TauriTerminalGateway write ordering (regression)", () => {
|
||||
beforeEach(() => invoke.mockReset());
|
||||
|
||||
it("delivers bytes to the backend in call order despite out-of-order resolution", async () => {
|
||||
// Each `write_terminal` invoke resolves after a delay that is the INVERSE of
|
||||
// its arrival order, so the *first* call resolves last. If writes were
|
||||
// fire-and-forget (no chaining), the backend would observe them reversed.
|
||||
const arrivals: string[] = [];
|
||||
let order = 0;
|
||||
invoke.mockImplementation((cmd: string, payload: { request: { data: number[] } }) => {
|
||||
if (cmd !== "write_terminal") return Promise.resolve();
|
||||
const callIndex = order++;
|
||||
const delay = (4 - callIndex) * 10; // 1st call → longest delay
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
arrivals.push(dec.decode(Uint8Array.from(payload.request.data)));
|
||||
resolve();
|
||||
}, delay);
|
||||
});
|
||||
});
|
||||
|
||||
const h = handle();
|
||||
const enc = new TextEncoder();
|
||||
// Fire five writes back-to-back, as fast typing would.
|
||||
const writes = ["a", "b", "c", "d", "e"].map((c) => h.write(enc.encode(c)));
|
||||
await Promise.all(writes);
|
||||
|
||||
// Backend stdin order MUST equal the order `write` was called.
|
||||
expect(arrivals).toEqual(["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("a rejected write does not block subsequent writes (chain survives errors)", async () => {
|
||||
const arrivals: string[] = [];
|
||||
let call = 0;
|
||||
invoke.mockImplementation(
|
||||
(_cmd: string, payload?: { request: { data: number[] } }) => {
|
||||
// Only `write_terminal` calls carry a payload; ignore any bare probe.
|
||||
if (!payload) return Promise.resolve();
|
||||
call++;
|
||||
if (call === 1) return Promise.reject(new Error("boom"));
|
||||
arrivals.push(dec.decode(Uint8Array.from(payload.request.data)));
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
const h = handle();
|
||||
const enc = new TextEncoder();
|
||||
const first = h.write(enc.encode("x")); // rejects
|
||||
const second = h.write(enc.encode("y")); // must still run, in order
|
||||
|
||||
// The rejected write surfaces its error to its own caller…
|
||||
let firstErr: unknown;
|
||||
await first.catch((e) => {
|
||||
firstErr = e;
|
||||
});
|
||||
expect(firstErr).toBeInstanceOf(Error);
|
||||
// …but the chain survives, so the next write still reaches the backend.
|
||||
await second;
|
||||
expect(arrivals).toEqual(["y"]);
|
||||
});
|
||||
|
||||
it("write nests sessionId + data array inside the request DTO", async () => {
|
||||
invoke.mockResolvedValue(undefined);
|
||||
const h = handle();
|
||||
await h.write(Uint8Array.from([104, 105])); // "hi"
|
||||
expect(invoke).toHaveBeenCalledWith("write_terminal", {
|
||||
request: { sessionId: "sess-1", data: [104, 105] },
|
||||
});
|
||||
});
|
||||
|
||||
it("resize forwards rows/cols inside the request DTO", async () => {
|
||||
invoke.mockResolvedValue(undefined);
|
||||
const h = handle();
|
||||
await h.resize(40, 120);
|
||||
expect(invoke).toHaveBeenCalledWith("resize_terminal", {
|
||||
request: { sessionId: "sess-1", rows: 40, cols: 120 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -48,12 +48,32 @@ export function makeTerminalHandle(
|
||||
sessionId: string,
|
||||
channel: Channel<number[]>,
|
||||
): TerminalHandle {
|
||||
// Serialise writes per handle. Each `write` chains its `invoke` after the
|
||||
// previous one resolves, so the order in which `write` is *called* is the
|
||||
// order the bytes reach the backend stdin — regardless of how Tauri's IPC
|
||||
// schedules concurrent `invoke`s. Without this, typing/pasting fast puts
|
||||
// several `invoke`s in flight at once and they can land out of order, garbling
|
||||
// the CLI input (e.g. "le même" → "le mO é J é IDEIDE…").
|
||||
//
|
||||
// The chain only sequences *ordering*; a failed write is swallowed for the
|
||||
// purpose of the chain (logged, then the chain continues) so one rejected
|
||||
// promise never blocks every subsequent keystroke. The error is still
|
||||
// surfaced to the caller of that specific `write` via its own promise.
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await invoke("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
});
|
||||
write(data: Uint8Array): Promise<void> {
|
||||
const run = chain.then(() =>
|
||||
invoke<void>("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
}),
|
||||
);
|
||||
// Keep the chain alive even if this write rejects: the next write must
|
||||
// still run. Swallow the error on the *chain* copy only — `run` keeps the
|
||||
// rejection so the caller can observe it.
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
await invoke("resize_terminal", {
|
||||
@ -105,4 +125,11 @@ export class TauriTerminalGateway implements TerminalGateway {
|
||||
scrollback: Uint8Array.from(res.scrollback),
|
||||
};
|
||||
}
|
||||
|
||||
async closeTerminal(sessionId: string): Promise<void> {
|
||||
// Kills the PTY by id (the backend `close_terminal` command). Used when a
|
||||
// cell's agent changes and the old PTY must be torn down even though its
|
||||
// owning view only ever detaches.
|
||||
await invoke("close_terminal", { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user