diff --git a/.changeset/no-planner-sessions.md b/.changeset/no-planner-sessions.md new file mode 100644 index 000000000..74e5427cb --- /dev/null +++ b/.changeset/no-planner-sessions.md @@ -0,0 +1,13 @@ +--- +"@sapiom/harness": minor +--- + +**Breaking.** Studio never creates a planner session. Selecting a project or its Plan Agents row shows the Agent Map from durable state and starts nothing; a project's first session is an ordinary session started explicitly, through the same createSession path as the + tab, and it has the Agent Map tools. + +Migration: + +- `HarnessSession.planning` is removed. Sessions persisted with planner metadata load and resume as ordinary sessions; the stale key is dropped on registry load and nothing on disk is deleted. Drop any read of `session.planning`. +- `POST /api/projects/:id/planner-sessions`, its `/messages` child and its `/greeting/retry` child now answer `410` with `planner_sessions_removed`. +- The eight `planner_session.*` and `planner_greeting.*` `AnalyticsEventType` members are no longer emitted. They stay in the union, deprecated, for one release so an exhaustive switch still compiles; they are removed in the next minor. + +The planner greeting coordinator, the planning session service and the planner profile are deleted. The Agent Map store, its MCP tools and the renderer are unchanged. diff --git a/.changeset/project-row-plus-is-new-agent.md b/.changeset/project-row-plus-is-new-agent.md new file mode 100644 index 000000000..970c47f18 --- /dev/null +++ b/.changeset/project-row-plus-is-new-agent.md @@ -0,0 +1,11 @@ +--- +"@sapiom/harness": minor +--- + +A project row's `+` is **New agent**, scoped to that project, and a plain session is no longer a row verb. + +Creation had been delegated to the pinned Agent Map: `mapOwnsCreation` is true for every project on a current server, and it gated both the row's create action and the empty project's create row, so neither rendered. The Agent Map has no create control of its own — its only route to generating agents was the planner session, which SAP-3143 removes. A project that already held agents was left with no scoped way to grow another; the rail's top CTA opens the composer with no project context and cannot create into an existing project. + +The `+` now opens the new-agent screen for the row's own project (`project-create-agent-{label}`), and a bare project keeps its distinct scaffold verb (`workspace-scaffold-{label}`). `project-start-session-{label}` is removed: a plain session starts from the tab strip, or from the **Start a session** on the project's own pane. The empty project still gets no create row of its own — its Agent Map row is the CTA. + +Follows design-eng `IA.md` 219 and D34(a); D34(e) and D35 item 6 for sessions belonging to the tab strip. diff --git a/.changeset/project-row-remove-action.md b/.changeset/project-row-remove-action.md new file mode 100644 index 000000000..59a7ce51a --- /dev/null +++ b/.changeset/project-row-remove-action.md @@ -0,0 +1,9 @@ +--- +"@sapiom/harness": patch +--- + +A project row's remove verb is a hover action, not an overflow menu. The `⋮` on every project row opened a 248px card to hold a single item — on plan-first projects its create item is suppressed, because the Agent Map owns creation, so the popover existed to carry one `Remove … from the rail`. That verb is now an `X` beside the session shortcut, hover-revealed like every other row action, and it opens the same confirmation as before: the project named, the count of running sessions it will end, and the statement that nothing on disk is touched. + +Row actions state their subject in the accessible name and the tooltip rather than in visible menu text. The `project-remove-{label}` testid is unchanged and now belongs to the button itself; `project-menu-{label}` and `project-menu-card-{label}` are gone, as is the `openProjectMenu` e2e helper. + +Follows design-eng D33: a project row's verbs are hover actions on the header, and a per-row menu would be a new idiom. diff --git a/packages/harness/src/core/paths.test.ts b/packages/harness/src/core/paths.test.ts index 1177e89ce..a690170ac 100644 --- a/packages/harness/src/core/paths.test.ts +++ b/packages/harness/src/core/paths.test.ts @@ -31,9 +31,6 @@ describe("resolveStatePaths", () => { expect(paths.settings).toBe(path.join(root, "settings.json")); expect(paths.studioProjects).toBe(path.join(root, "studio-projects.json")); expect(paths.agentMap).toBe(path.join(root, "agent-map")); - expect(paths.plannerSessions).toBe( - path.join(root, "agent-map", "planner-sessions"), - ); expect(paths.generated).toBe(path.join(root, "generated")); expect(paths.sampleProject).toBe(path.join(root, "sample-project")); }); @@ -48,9 +45,6 @@ describe("resolveStatePaths", () => { expect(paths.settings).toBe("/scratch/state/settings.json"); expect(paths.studioProjects).toBe("/scratch/state/studio-projects.json"); expect(paths.agentMap).toBe("/scratch/state/agent-map"); - expect(paths.plannerSessions).toBe( - "/scratch/state/agent-map/planner-sessions", - ); expect(paths.generated).toBe("/scratch/state/generated"); expect(paths.sampleProject).toBe("/scratch/state/sample-project"); }); diff --git a/packages/harness/src/core/paths.ts b/packages/harness/src/core/paths.ts index 587bb026a..428990588 100644 --- a/packages/harness/src/core/paths.ts +++ b/packages/harness/src/core/paths.ts @@ -28,7 +28,6 @@ export interface HarnessStatePaths { studioProjects: string; pendingSecrets: string; agentMap: string; - plannerSessions: string; generated: string; records: string; sampleProject: string; @@ -60,7 +59,6 @@ export function resolveStatePaths(stateRoot?: string): HarnessStatePaths { studioProjects: join(root, relativeToHome(HARNESS_PATHS.studioProjects)), pendingSecrets: join(root, relativeToHome(HARNESS_PATHS.pendingSecrets)), agentMap: join(root, relativeToHome(HARNESS_PATHS.agentMap)), - plannerSessions: join(root, "agent-map", "planner-sessions"), generated: join(root, relativeToHome(HARNESS_PATHS.generated)), records: join(root, relativeToHome(HARNESS_PATHS.records)), sampleProject: join(root, relativeToHome(HARNESS_PATHS.sampleProject)), diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts deleted file mode 100644 index dd1b46c9e..000000000 --- a/packages/harness/src/core/planner-greeting.test.ts +++ /dev/null @@ -1,962 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; -import type { SessionManager } from "./session-manager.js"; -import { - PlannerGreetingCoordinator, - PlannerGreetingRetryUnavailableError, - plannerGreetingPrompt, -} from "./planner-greeting.js"; - -function event( - sessionId: string, - type: AnalyticsEvent["type"], - payload: Record, -): AnalyticsEvent { - return { - eventId: `event-${type}`, - seq: 1, - ts: "2026-09-01T00:00:00.000Z", - userId: "user-1", - tenantId: null, - machineId: "machine-1", - harnessSessionId: sessionId, - agentSessionId: "agent-1", - harness: "codex", - type, - payload, - }; -} - -function plannerSession(id = "session-1"): HarnessSession { - return { - id, - agentSessionId: "agent-1", - harness: "codex", - cwd: "/private/project", - title: "project", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: true, - planning: { - identity: { - projectId: "project-1", - sessionId: id, - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, - queuedInputIds: [], - }, - }; -} - -describe("PlannerGreetingCoordinator", () => { - let root: string; - let session: HarnessSession; - let submitted: string[]; - let manager: SessionManager; - - beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-greeting-")); - session = plannerSession(); - submitted = []; - manager = { - get: (id: string) => (id === session.id ? session : undefined), - setPlanningMetadata: async (_id: string, metadata: NonNullable) => { - session.planning = structuredClone(metadata); - }, - submitInput: async (_id: string, text: string) => { - submitted.push(text); - return true; - }, - } as unknown as SessionManager; - }); - - afterEach(async () => { - vi.useRealTimers(); - await fs.rm(root, { recursive: true, force: true }); - }); - - it("persists one ready-gated greeting, then releases accepted input FIFO", async () => { - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.onSessionStatus(session); - expect(submitted).toHaveLength(1); - const greeting = submitted[0]!; - - await coordinator.enqueue(session.id, "first user message"); - await coordinator.enqueue(session.id, "second user message"); - expect(submitted).toEqual([greeting]); - - const localPrompt = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: greeting }), - ); - expect(localPrompt.payload).toMatchObject({ - prompt: greeting, - plannerOrigin: "infrastructure", - }); - expect(coordinator.redactForTelemetry(localPrompt).payload).not.toHaveProperty( - "prompt", - ); - - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "What should we build?" }), - ); - expect(submitted).toEqual([ - greeting, - "first user message", - "second user message", - ]); - expect(session.planning).toMatchObject({ - greeting: { status: "delivered", messageId: "event-turn.completed" }, - queuedInputIds: [], - }); - const durable = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - expect(durable.inputs).toEqual([]); - }); - - it("rejects a planner session identity that could escape the queue root", async () => { - session = plannerSession("../outside-planner-root"); - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - - await expect( - coordinator.register(session, { emptyProject: true, mode: "created" }), - ).rejects.toThrow("invalid planner session storage identity"); - await expect(fs.readdir(root)).resolves.toEqual([]); - }); - - it("recovers a generating restart without duplicating onboarding", async () => { - const first = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await first.register(session, { emptyProject: true, mode: "created" }); - await first.onSessionStatus(session); - await first.enqueue(session.id, "continue with my request"); - const greeting = submitted[0]!; - - const restarted = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await restarted.register(session, { emptyProject: true, mode: "boot" }); - - expect(submitted).toEqual([greeting, "continue with my request"]); - expect(session.planning).toMatchObject({ - greeting: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], - }); - }); - - it("lets queued user work win an in-flight greeting failure", async () => { - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: false, mode: "created" }); - await coordinator.onSessionStatus(session); - const greeting = submitted[0]!; - await coordinator.enqueue(session.id, "review the existing plan"); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: greeting }), - ); - - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: null }), - ); - - expect(submitted).toEqual([greeting, "review the existing plan"]); - expect(session.planning?.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); - }); - - it("bounds retry to failed, retryable sessions without accepted user work", async () => { - session.planning!.greeting = { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - }; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.retry(session.id); - expect(submitted).toHaveLength(1); - expect(session.planning?.greeting.status).toBe("generating"); - await expect(coordinator.retry(session.id)).rejects.toBeInstanceOf( - PlannerGreetingRetryUnavailableError, - ); - }); - - it("keeps a same-process generating attempt live on idempotent registration", async () => { - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.onSessionStatus(session); - const generating = structuredClone(session.planning!.greeting); - - await coordinator.register(session, { emptyProject: true, mode: "live" }); - - expect(session.planning?.greeting).toEqual(generating); - expect(submitted).toHaveLength(1); - }); - - it("keeps resume-suppressed skipped state authoritative over a stale queue file", async () => { - const first = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await first.register(session, { emptyProject: true, mode: "created" }); - await first.onSessionStatus(session); - await first.enqueue(session.id, "continue from durable input"); - session.planning!.greeting = { status: "skipped", reason: "user-proceeded" }; - - const resumed = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await resumed.register(session, { emptyProject: true, mode: "resumed" }); - - expect(session.planning).toMatchObject({ - greeting: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], - }); - expect(submitted.at(-1)).toBe("continue from durable input"); - }); - - it("uses its accepted ledger to finish a failed dequeue after restart without duplicate or loss", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let failAcceptedDequeue = true; - const first = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: unknown[]; - }; - if ( - failAcceptedDequeue && - submitted.length === 1 && - state.dispatchingInputId === null && - state.inputs.length === 0 - ) { - failAcceptedDequeue = false; - throw new Error("injected queue cleanup failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - }); - await first.register(session, { emptyProject: true, mode: "created" }); - - await expect( - first.enqueue(session.id, "deliver exactly once"), - ).resolves.toBeDefined(); - expect(submitted).toEqual(["deliver exactly once"]); - const durableBeforeRestart = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { dispatchingInputId: string | null; inputs: Array<{ id: string }> }; - expect(durableBeforeRestart.dispatchingInputId).toBe( - durableBeforeRestart.inputs[0]!.id, - ); - const acceptedBeforeRestart = JSON.parse( - await fs.readFile( - path.join(root, session.id, "accepted-inputs.json"), - "utf8", - ), - ) as { inputIds: string[] }; - expect(acceptedBeforeRestart.inputIds).toEqual([ - durableBeforeRestart.inputs[0]!.id, - ]); - - const restarted = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await restarted.register(session, { emptyProject: true, mode: "boot" }); - - expect(submitted).toEqual(["deliver exactly once"]); - expect(session.planning?.queuedInputIds).toEqual([]); - const durableAfterRestart = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { dispatchingInputId: string | null; inputs: unknown[] }; - expect(durableAfterRestart).toMatchObject({ - dispatchingInputId: null, - inputs: [], - }); - }); - - it("does not publish a phantom dispatch intent when its durable write fails", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let failIntentWrite = true; - const lifecycle: unknown[] = []; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: unknown[]; - }; - if ( - failIntentWrite && - state.dispatchingInputId !== null && - state.inputs.length === 1 - ) { - failIntentWrite = false; - throw new Error("transient dispatch-intent write failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - onEvent: (value) => { - lifecycle.push(value); - }, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - await coordinator.enqueue(session.id, "first was never submitted"); - expect(submitted).toEqual([]); - await coordinator.enqueue(session.id, "second triggers a safe retry"); - - expect(submitted).toEqual([ - "first was never submitted", - "second triggers a safe retry", - ]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect(lifecycle).not.toContainEqual( - expect.objectContaining({ - name: "planner_session.input_delivery_uncertain", - }), - ); - }); - - it("does not resurrect an enqueue whose primary queue write was rejected", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let rejectFirstEnqueue = true; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: Array<{ text: string }>; - }; - if ( - rejectFirstEnqueue && - state.dispatchingInputId === null && - state.inputs.some((input) => input.text === "rejected input") - ) { - rejectFirstEnqueue = false; - throw new Error("primary enqueue write rejected"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - await expect( - coordinator.enqueue(session.id, "rejected input"), - ).rejects.toThrow("planner state persistence failed"); - await coordinator.enqueue(session.id, "accepted input"); - - expect(submitted).toEqual(["accepted input"]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ).not.toContain("rejected input"); - }); - - it("keeps dispatching when only the secondary sessions projection fails", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - const setPlanningMetadata = vi.fn(async () => { - throw new Error("sessions.json projection unavailable"); - }); - manager.setPlanningMetadata = setPlanningMetadata; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - await expect( - coordinator.enqueue(session.id, "deliver from the authoritative queue"), - ).resolves.toMatchObject({ queuedInputIds: [] }); - - expect(submitted).toEqual(["deliver from the authoritative queue"]); - expect(setPlanningMetadata).toHaveBeenCalled(); - const durable = JSON.parse( - await fs.readFile( - path.join(root, session.id, "input-queue.json"), - "utf8", - ), - ) as { dispatchingInputId: string | null; inputs: unknown[] }; - expect(durable).toMatchObject({ - dispatchingInputId: null, - inputs: [], - }); - }); - - it("resolves an orphaned dispatch as uncertain and lets the later FIFO continue", async () => { - session.ready = false; - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let firstSubmitAttempted = false; - let failRollback = true; - manager.submitInput = async (_id: string, text: string) => { - if (!firstSubmitAttempted) { - firstSubmitAttempted = true; - throw new Error("process ended before PTY acceptance was knowable"); - } - submitted.push(text); - return true; - }; - const first = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async (file, value) => { - const state = value as { - dispatchingInputId: string | null; - inputs: unknown[]; - }; - if ( - failRollback && - firstSubmitAttempted && - state.dispatchingInputId === null && - state.inputs.length === 2 - ) { - failRollback = false; - throw new Error("simulated crash before intent rollback"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - }); - await first.register(session, { emptyProject: true, mode: "created" }); - await first.enqueue(session.id, "delivery became uncertain"); - await first.enqueue(session.id, "must still make progress"); - session.ready = true; - await first.onSessionStatus(session); - - const lifecycle: unknown[] = []; - const restarted = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - onEvent: (value) => { - lifecycle.push(value); - }, - }); - await restarted.register(session, { emptyProject: true, mode: "boot" }); - - expect(submitted).toEqual(["must still make progress"]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect(lifecycle).toContainEqual( - expect.objectContaining({ - name: "planner_session.input_delivery_uncertain", - errorCode: "delivery_uncertain", - queueDepth: 1, - }), - ); - expect(JSON.stringify(lifecycle)).not.toContain("delivery became uncertain"); - }); - - it("compacts a stale accepted-ledger entry before acknowledging later input", async () => { - session.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - let failFirstCleanup = true; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeAcceptedLedger: async (file, value) => { - const ledger = value as { inputIds: string[] }; - if (failFirstCleanup && ledger.inputIds.length === 0) { - failFirstCleanup = false; - throw new Error("injected accepted-ledger cleanup failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - await coordinator.enqueue(session.id, "first accepted input"); - await coordinator.enqueue(session.id, "second accepted input"); - - expect(submitted).toEqual(["first accepted input", "second accepted input"]); - expect(session.planning?.queuedInputIds).toEqual([]); - expect( - JSON.parse( - await fs.readFile( - path.join(root, session.id, "accepted-inputs.json"), - "utf8", - ), - ), - ).toEqual({ schemaVersion: 1, inputIds: [] }); - }); - - it("bounds pending readiness, then drains its durable FIFO when readiness arrives", async () => { - vi.useFakeTimers(); - session.ready = false; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 100, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.enqueue(session.id, "queued while booting"); - await vi.advanceTimersByTimeAsync(101); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - expect(session.planning?.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); - expect(submitted).toEqual([]); - - session.ready = true; - await coordinator.onSessionStatus(session); - expect(submitted).toEqual(["queued while booting"]); - expect(session.planning?.queuedInputIds).toEqual([]); - }); - - it("contains timer persistence rejection with only a bounded local classification", async () => { - vi.useFakeTimers(); - session.ready = false; - let rejectWrites = false; - const localErrors = vi.spyOn(console, "error").mockImplementation(() => {}); - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 100, - writeState: async (file, value) => { - if (rejectWrites) { - throw new Error("/private/customer provider-secret"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - - rejectWrites = true; - await vi.advanceTimersByTimeAsync(101); - await vi.waitFor(() => { - expect(localErrors).toHaveBeenCalledWith( - "[harness] planner greeting timeout transition failed: persistence_failed", - ); - }); - - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "persistence_failed", - }); - expect(JSON.stringify(localErrors.mock.calls)).not.toContain( - "private/customer", - ); - expect(JSON.stringify(localErrors.mock.calls)).not.toContain( - "provider-secret", - ); - localErrors.mockRestore(); - }); - - it("keeps a queued planner message out of the PTY when its live dispatch authority is rebound before readiness", async () => { - session.ready = false; - let authorized = true; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - canDispatch: async () => authorized, - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.enqueue(session.id, "private queued plan request"); - expect(submitted).toEqual([]); - - authorized = false; - session.ready = true; - await coordinator.onSessionStatus(session); - - expect(submitted).toEqual([]); - expect(session.planning?.queuedInputIds).toHaveLength(1); - }); - - it("does not extend the readiness deadline on live re-registration", async () => { - vi.useFakeTimers(); - session.ready = false; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 100, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await vi.advanceTimersByTimeAsync(60); - await coordinator.register(session, { emptyProject: true, mode: "live" }); - await vi.advanceTimersByTimeAsync(41); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "session_not_ready", - }); - }); - - it("classifies an exit from pending and clears stale correlation state", async () => { - session.ready = false; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - session.status = "exited"; - await coordinator.onSessionStatus(session); - - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: false, - errorCode: "session_exited", - }); - const decorated = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { - prompt: plannerGreetingPrompt(true), - }), - ); - expect(decorated.payload).not.toHaveProperty("plannerOrigin"); - }); - - it("uses unique prompts and FIFO tombstones so a late old turn cannot deliver a retry", async () => { - vi.useFakeTimers(); - const ids = ["attempt-1", "attempt-2"]; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - generateId: () => ids.shift() ?? "state-write", - deliveryTimeoutMs: 100, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - const oldPrompt = submitted[0]!; - await vi.advanceTimersByTimeAsync(101); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "delivery_timeout", - }); - - await coordinator.retry(session.id); - const retryPrompt = submitted[1]!; - expect(retryPrompt).not.toBe(oldPrompt); - const lateOldPrompt = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: oldPrompt }), - ); - expect(lateOldPrompt.payload.plannerAttemptId).toBe("attempt-1"); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { - assistantText: "Late answer from the first attempt", - }), - ); - expect(session.planning?.greeting).toEqual({ - status: "generating", - attemptId: "attempt-2", - }); - - const retryEvent = coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: retryPrompt }), - ); - expect(retryEvent.payload.plannerAttemptId).toBe("attempt-2"); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { - assistantText: "What kind of agent architecture should we build?", - }), - ); - - expect(session.planning?.greeting).toEqual({ - status: "delivered", - messageId: "event-turn.completed", - }); - }); - - it("pre-registers correlation before submit and removes it on a proven false return", async () => { - const holder: { coordinator?: PlannerGreetingCoordinator } = {}; - const synchronousPrompts: AnalyticsEvent[] = []; - manager.submitInput = async (_id, prompt) => { - synchronousPrompts.push( - holder.coordinator!.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt }), - ), - ); - return true; - }; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - generateId: () => "attempt-synchronous", - deliveryTimeoutMs: 60_000, - }); - holder.coordinator = coordinator; - await coordinator.register(session, { emptyProject: true, mode: "created" }); - expect(synchronousPrompts[0]?.payload.plannerAttemptId).toBe( - "attempt-synchronous", - ); - - session = plannerSession("session-false"); - manager.submitInput = async () => false; - const rejected = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await rejected.register(session, { emptyProject: true, mode: "created" }); - const decorated = rejected.decorateLocalEvent( - event(session.id, "prompt.submitted", { - prompt: plannerGreetingPrompt(true), - }), - ); - - expect(decorated.payload).not.toHaveProperty("plannerOrigin"); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: false, - errorCode: "session_exited", - }); - }); - - it("consumes unmatched prompt completions before the active greeting barrier", async () => { - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - generateId: () => "attempt-1", - deliveryTimeoutMs: 60_000, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - const greetingPrompt = submitted[0]!; - - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: "unmatched user turn" }), - ); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: greetingPrompt }), - ); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "user answer" }), - ); - expect(session.planning?.greeting).toEqual({ - status: "generating", - attemptId: "attempt-1", - }); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "greeting answer" }), - ); - expect(session.planning?.greeting.status).toBe("delivered"); - }); - - it("consumes a late completion while failed before a retry begins", async () => { - vi.useFakeTimers(); - const ids = ["attempt-1", "attempt-2"]; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - generateId: () => ids.shift() ?? "state-write", - deliveryTimeoutMs: 100, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: submitted[0]! }), - ); - await vi.advanceTimersByTimeAsync(101); - await (coordinator as unknown as { writes: Map> }) - .writes.get(session.id); - - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "late old answer" }), - ); - await coordinator.retry(session.id); - coordinator.decorateLocalEvent( - event(session.id, "prompt.submitted", { prompt: submitted[1]! }), - ); - await coordinator.onEventPersisted( - event(session.id, "turn.completed", { assistantText: "retry answer" }), - ); - expect(session.planning?.greeting.status).toBe("delivered"); - }); - - it("quarantines a corrupt queue without preventing local registration", async () => { - const dir = path.join(root, session.id); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "input-queue.json"), "{secret-corrupt"); - session.planning!.queuedInputIds = ["stale-registry-input"]; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - - await expect( - coordinator.register(session, { emptyProject: true, mode: "boot" }), - ).resolves.toBeUndefined(); - const names = await fs.readdir(dir); - expect(names).toContain("input-queue.json"); - expect(names.some((name) => name.startsWith("input-queue.corrupt-"))).toBe(true); - expect(session.planning?.queuedInputIds).toEqual([]); - }); - - it("durably classifies queue persistence failure without raw error content", async () => { - const events: unknown[] = []; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - writeState: async () => { - throw new Error("/private/path provider secret"); - }, - onEvent: (value) => { - events.push(value); - }, - }); - - await expect( - coordinator.register(session, { emptyProject: true, mode: "created" }), - ).rejects.toThrow("planner state persistence failed"); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "persistence_failed", - }); - expect( - ( - coordinator as unknown as { - states: Map }>; - } - ).states.get(session.id)?.metadata.greeting, - ).toEqual(session.planning?.greeting); - await expect(coordinator.retry(session.id)).rejects.toThrow( - "planner state persistence failed", - ); - expect(session.planning?.greeting).toEqual({ - status: "failed", - retryable: true, - errorCode: "persistence_failed", - }); - expect(JSON.stringify(events)).not.toContain("private/path"); - expect(JSON.stringify(events)).not.toContain("provider secret"); - }); - - it("emits bounded lifecycle codes without prompts, paths, or provider errors", async () => { - const lifecycle: unknown[] = []; - manager.submitInput = async () => { - throw new Error("provider said /private/customer secret-token"); - }; - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - onEvent: (value) => { - lifecycle.push(value); - }, - }); - await coordinator.register(session, { emptyProject: true, mode: "created" }); - await coordinator.onSessionStatus(session); - - expect(lifecycle).toEqual([ - expect.objectContaining({ - name: "planner_greeting.attempted", - projectId: "project-1", - sessionId: session.id, - }), - expect.objectContaining({ - name: "planner_greeting.failed", - errorCode: "injection_failed", - }), - ]); - const serialized = JSON.stringify(lifecycle); - expect(serialized).not.toContain("Agent Studio control turn"); - expect(serialized).not.toContain("/private/customer"); - expect(serialized).not.toContain("secret-token"); - }); -}); - -describe("plannerGreetingPrompt", () => { - it("keeps the automatic greeting scoped to collaborative planning and one question", () => { - const empty = plannerGreetingPrompt(true); - const existing = plannerGreetingPrompt(false); - for (const prompt of [empty, existing]) { - expect(prompt).toContain("project planning agent"); - expect(prompt).toContain("agents, responsibilities, data flow, resources, and connectors"); - expect(prompt).toContain("exactly one open-ended question"); - expect(prompt).toContain("Do not propose an architecture"); - expect(prompt).toContain("invoke tools"); - } - expect(empty).toContain( - "what kind of agent architecture the user wants to build", - ); - expect(existing).toContain("current plan exists"); - const attempted = plannerGreetingPrompt(true, "attempt-private-1"); - expect(attempted).toContain("Internal attempt ID: attempt-private-1"); - expect(attempted).toContain("Never mention this ID"); - expect(empty).not.toContain("attempt-private-1"); - }); -}); diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts deleted file mode 100644 index facd4e80c..000000000 --- a/packages/harness/src/core/planner-greeting.ts +++ /dev/null @@ -1,1489 +0,0 @@ -import { randomUUID } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - -import type { - PlannerGreetingErrorCode, - PlannerLifecycleEvent, - PlannerQueuedInput, - PlannerSessionMetadata, -} from "../shared/agent-map.js"; -import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; -import { - SessionInputGuardRejectedError, - SessionManager, - SessionNotReadyError, -} from "./session-manager.js"; - -interface PersistedPlannerState { - schemaVersion: 1; - metadata: PlannerSessionMetadata; - inputs: PlannerQueuedInput[]; - /** - * Durable write-ahead intent for the one FIFO head that may be crossing the - * PTY boundary. An unresolved intent is never replayed automatically after a - * restart because the process cannot prove whether the PTY accepted it. - */ - dispatchingInputId: string | null; - retryCount: number; - emptyProject: boolean; -} - -interface AcceptedInputLedger { - schemaVersion: 1; - inputIds: string[]; -} - -interface ExpectedPrompt { - kind: "greeting" | "user"; - id: string; - text: string; - /** A failed/timed-out greeting stays as a FIFO tombstone so a late hook - * cannot be mistaken for a later retry. */ - retired?: boolean; -} - -interface ObservedGreetingAttempt { - id: string; - retired: boolean; -} - -interface AttemptTimer { - key: "pending" | string; - handle: ReturnType; -} - -export type PlannerRegistrationMode = - | "boot" - | "created" - | "live" - | "resumed" - | "rehydrated"; - -export interface PlannerRegistrationContext { - emptyProject: boolean; - mode: PlannerRegistrationMode; -} - -export interface PlannerGreetingCoordinatorOptions { - root: string; - sessionManager: SessionManager; - now?: () => string; - generateId?: () => string; - /** Applies both while waiting for readiness and while awaiting a model turn. */ - deliveryTimeoutMs?: number; - /** Test seam for classifying queue-store failures without exposing raw errors. */ - writeState?: (file: string, state: unknown) => Promise; - /** Test seam for accepted-ledger cleanup/commit failures. */ - writeAcceptedLedger?: (file: string, state: unknown) => Promise; - /** Test seam for the atomic predecessor-to-successor queue handoff. */ - moveStateDirectory?: (source: string, target: string) => Promise; - /** Live authorization gate checked immediately before every PTY dispatch. */ - canDispatch?: (session: HarnessSession) => boolean | Promise; - onEvent?: (event: PlannerLifecycleEvent) => Promise | void; -} - -export class PlannerGreetingRetryUnavailableError extends Error { - readonly code = "greeting_retry_unavailable"; - - constructor() { - super("greeting retry is not available"); - this.name = "PlannerGreetingRetryUnavailableError"; - } -} - -export class PlannerDispatchForbiddenError extends Error { - readonly code = "planner_dispatch_forbidden"; - - constructor() { - super("planner session is no longer authorized for this project binding"); - this.name = "PlannerDispatchForbiddenError"; - } -} - -const MAX_RETRIES = 2; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isTerminal(metadata: PlannerSessionMetadata): boolean { - return ( - metadata.greeting.status === "delivered" || - metadata.greeting.status === "skipped" - ); -} - -function isPersistedPlannerState( - value: unknown, - session: HarnessSession, -): value is PersistedPlannerState { - if (!isRecord(value) || value.schemaVersion !== 1 || !session.planning) { - return false; - } - const metadata = value.metadata; - if (!isRecord(metadata) || !isRecord(metadata.identity)) return false; - const expected = session.planning.identity; - if ( - metadata.identity.role !== "map-planner" || - metadata.identity.sessionId !== expected.sessionId || - metadata.identity.projectId !== expected.projectId || - metadata.identity.userId !== expected.userId || - !Array.isArray(metadata.queuedInputIds) || - !metadata.queuedInputIds.every((id) => typeof id === "string") || - !isRecord(metadata.greeting) || - typeof metadata.greeting.status !== "string" || - !["pending", "generating", "delivered", "failed", "skipped"].includes( - metadata.greeting.status, - ) || - !Array.isArray(value.inputs) || - (value.dispatchingInputId !== undefined && - value.dispatchingInputId !== null && - typeof value.dispatchingInputId !== "string") || - !Number.isSafeInteger(value.retryCount) || - (value.retryCount as number) < 0 || - (value.retryCount as number) > MAX_RETRIES || - typeof value.emptyProject !== "boolean" - ) { - return false; - } - const inputs = value.inputs; - if ( - !inputs.every( - (input) => - isRecord(input) && - typeof input.id === "string" && - input.id !== "" && - input.sessionId === session.id && - typeof input.text === "string" && - input.text.length <= 100_000 && - typeof input.acceptedAt === "string", - ) || - metadata.queuedInputIds.length !== inputs.length || - metadata.queuedInputIds.some( - (id, index) => id !== (inputs[index] as Record).id, - ) || - (typeof value.dispatchingInputId === "string" && - value.dispatchingInputId !== - (inputs[0] as Record | undefined)?.id) - ) { - return false; - } - const greeting = metadata.greeting; - switch (greeting.status) { - case "pending": - return true; - case "generating": - return typeof greeting.attemptId === "string" && greeting.attemptId !== ""; - case "delivered": - return typeof greeting.messageId === "string" && greeting.messageId !== ""; - case "failed": - return ( - typeof greeting.retryable === "boolean" && - typeof greeting.errorCode === "string" && - [ - "session_not_ready", - "session_exited", - "injection_failed", - "model_turn_failed", - "delivery_timeout", - "persistence_failed", - ].includes(greeting.errorCode) - ); - case "skipped": - return greeting.reason === "user-proceeded"; - default: - return false; - } -} - -/** A directory rename can commit before the successor rewrites the embedded - * session identity. The trusted `rehydratedFrom` link is the recovery marker: - * accept only an otherwise-valid predecessor state with the same scoped - * project/user/role, then re-key every identity-bearing field in memory. */ -function adoptRehydratedState( - value: unknown, - session: HarnessSession, -): PersistedPlannerState | null { - if (!session.planning || !session.rehydratedFrom) return null; - const predecessorId = session.rehydratedFrom; - const predecessor: HarnessSession = { - ...session, - id: predecessorId, - planning: { - ...structuredClone(session.planning), - identity: { - ...structuredClone(session.planning.identity), - sessionId: predecessorId, - }, - }, - }; - if (!isPersistedPlannerState(value, predecessor)) return null; - return { - ...structuredClone(value), - metadata: { - ...structuredClone(value.metadata), - identity: structuredClone(session.planning.identity), - }, - inputs: value.inputs.map((input) => ({ - ...structuredClone(input), - sessionId: session.id, - })), - dispatchingInputId: value.dispatchingInputId ?? null, - }; -} - -export function plannerGreetingPrompt( - emptyProject: boolean, - attemptId?: string, -): string { - const question = emptyProject - ? "Ask exactly one open-ended question about what kind of agent architecture the user wants to build." - : "Briefly acknowledge that a current plan exists, then ask exactly one open-ended question about what the user wants to review, extend, or change."; - return [ - "This is a private Agent Studio control turn.", - "Respond as the project planning agent with one brief greeting.", - "Explain that you and the user will plan the agents, responsibilities, data flow, resources, and connectors together.", - question, - "Do not propose an architecture, create nodes or relationships, invoke tools, or ask a second question before the user replies.", - ...(attemptId - ? [`Internal attempt ID: ${attemptId}. Never mention this ID in your response.`] - : []), - ].join(" "); -} - -const PLANNER_SESSION_SOURCES = new Set([ - "startup", - "resume", - "clear", - "compact", - "codex", -]); -const MAX_TELEMETRY_TOKEN_COUNT = 1_000_000_000_000; - -function telemetrySource(value: unknown): string { - return typeof value === "string" && PLANNER_SESSION_SOURCES.has(value) - ? value - : "unknown"; -} - -function telemetryTokenCount(value: unknown): number | null { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - return null; - } - return Math.min(Math.trunc(value), MAX_TELEMETRY_TOKEN_COUNT); -} - -function telemetryUsage(value: unknown): Record | null { - if (!isRecord(value)) return null; - const inputTokens = telemetryTokenCount(value.inputTokens); - const outputTokens = telemetryTokenCount(value.outputTokens); - if (inputTokens === null && outputTokens === null) return null; - return { inputTokens, outputTokens }; -} - -function telemetryPayload(event: AnalyticsEvent): Record { - switch (event.type) { - case "session.start": - return { source: telemetrySource(event.payload.source), planner: true }; - case "prompt.submitted": - return { - planner: true, - origin: event.payload.plannerOrigin ?? "user", - ...(typeof event.payload.plannerInputId === "string" - ? { plannerInputId: event.payload.plannerInputId } - : {}), - ...(typeof event.payload.plannerAttemptId === "string" - ? { plannerAttemptId: event.payload.plannerAttemptId } - : {}), - }; - case "tool.call": - return { planner: true, toolObserved: true }; - case "turn.completed": - return { - planner: true, - hasAssistantText: - typeof event.payload.assistantText === "string" && - event.payload.assistantText.length > 0, - // Provider/model text is never remotely projected. Even a syntactically - // plausible model identifier is an attacker-controlled covert channel. - modelReported: - typeof event.payload.model === "string" && - event.payload.model.length > 0, - usage: telemetryUsage(event.payload.usage), - }; - default: - return { planner: true }; - } -} - -export class PlannerGreetingCoordinator { - private readonly root: string; - private readonly now: () => string; - private readonly generateId: () => string; - private readonly deliveryTimeoutMs: number; - private readonly states = new Map(); - private readonly writes = new Map>(); - private readonly expected = new Map(); - private readonly observedAttempts = new Map< - string, - Array - >(); - private readonly correlationOverflow = new Set(); - private readonly timers = new Map(); - /** Status hooks can race a freshly spawned PTY ahead of register/handoff. */ - private readonly registeredSessions = new Set(); - /** A successfully handed-off predecessor can never dispatch or mutate its - * retired FIFO again, even if a late status/hook callback arrives. */ - private readonly retiredSessions = new Set(); - /** Successors loaded after a crash between the atomic directory move and - * the best-effort embedded-identity rewrite. */ - private readonly adoptedSessions = new Set(); - - constructor(private readonly options: PlannerGreetingCoordinatorOptions) { - this.root = path.resolve(options.root); - this.now = options.now ?? (() => new Date().toISOString()); - this.generateId = options.generateId ?? randomUUID; - this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 45_000; - } - - private sessionDirectory(sessionId: string): string { - const directory = path.resolve(this.root, sessionId); - const rootPrefix = `${this.root}${path.sep}`; - if (!directory.startsWith(rootPrefix)) { - throw new Error("invalid planner session storage identity"); - } - return directory; - } - - private file(sessionId: string): string { - return path.join(this.sessionDirectory(sessionId), "input-queue.json"); - } - - private acceptedFile(sessionId: string): string { - return path.join(this.sessionDirectory(sessionId), "accepted-inputs.json"); - } - - private emit(event: PlannerLifecycleEvent): void { - try { - void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); - } catch { - // Telemetry is best effort and must never change planner semantics. - } - } - - private async canDispatch(session: HarnessSession): Promise { - try { - return (await this.options.canDispatch?.(session)) ?? true; - } catch { - return false; - } - } - - private serialize(sessionId: string, operation: () => Promise): Promise { - const prior = this.writes.get(sessionId) ?? Promise.resolve(); - const next = prior.catch(() => {}).then(operation); - this.writes.set(sessionId, next); - void next.then( - () => { - if (this.writes.get(sessionId) === next) this.writes.delete(sessionId); - }, - () => { - if (this.writes.get(sessionId) === next) this.writes.delete(sessionId); - }, - ); - return next; - } - - private newState( - session: HarnessSession, - emptyProject: boolean, - ): PersistedPlannerState { - if (!session.planning) throw new Error("planner metadata missing"); - return { - schemaVersion: 1, - metadata: { - ...structuredClone(session.planning), - // The queue file owns FIFO membership. If that file is missing or was - // quarantined, stale registry IDs cannot resurrect content we no - // longer possess or make the replacement state invalid on next boot. - queuedInputIds: [], - }, - inputs: [], - dispatchingInputId: null, - retryCount: 0, - emptyProject, - }; - } - - private async quarantine(sessionId: string): Promise { - const file = this.file(sessionId); - const quarantine = path.join( - path.dirname(file), - `input-queue.corrupt-${this.now().replace(/[^0-9A-Za-z]/g, "-")}-${randomUUID()}.json`, - ); - await fs.rename(file, quarantine).catch(() => {}); - } - - private async load( - session: HarnessSession, - emptyProject = true, - ): Promise { - const cached = this.states.get(session.id); - // Every transition works on an isolated snapshot. Nothing may mutate the - // authoritative cache until persist() commits the primary queue file. - if (cached) return structuredClone(cached); - let state: PersistedPlannerState; - try { - const parsed: unknown = JSON.parse( - await fs.readFile(this.file(session.id), "utf8"), - ); - if (isPersistedPlannerState(parsed, session)) { - state = { - ...parsed, - // Backward-compatible with queue files written by the first SAP-3055 - // review head before dispatch intent became explicit. - dispatchingInputId: parsed.dispatchingInputId ?? null, - }; - } else { - const adopted = adoptRehydratedState(parsed, session); - if (!adopted) throw new Error("invalid planner state"); - state = adopted; - this.adoptedSessions.add(session.id); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - // A single damaged or unreadable session queue is local corruption, - // never a reason to prevent the rest of the harness from booting. - await this.quarantine(session.id); - } - state = this.newState(session, emptyProject); - } - this.states.set(session.id, structuredClone(state)); - return state; - } - - private async writeState(file: string, state: PersistedPlannerState): Promise { - if (this.options.writeState) { - await this.options.writeState(file, structuredClone(state)); - return; - } - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`; - await fs.writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { - encoding: "utf8", - mode: 0o600, - }); - await fs.rename(tmp, file); - } - - private async acceptedInputIds( - sessionId: string, - ): Promise | null> { - const file = this.acceptedFile(sessionId); - try { - const decoded: unknown = JSON.parse(await fs.readFile(file, "utf8")); - if ( - !isRecord(decoded) || - decoded.schemaVersion !== 1 || - !Array.isArray(decoded.inputIds) || - !decoded.inputIds.every( - (inputId) => typeof inputId === "string" && inputId !== "", - ) - ) { - throw new Error("invalid accepted-input ledger"); - } - return new Set(decoded.inputIds); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(); - // An unreadable acknowledgement is safety-significant. Keep the queue's - // write-ahead intent unresolved instead of guessing and replaying it. - const quarantine = path.join( - path.dirname(file), - `accepted-inputs.corrupt-${this.now().replace(/[^0-9A-Za-z]/g, "-")}-${randomUUID()}.json`, - ); - await fs.rename(file, quarantine).catch(() => {}); - return null; - } - } - - private async writeAcceptedInputIds( - sessionId: string, - inputIds: readonly string[], - ): Promise { - const file = this.acceptedFile(sessionId); - const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; - const ledger: AcceptedInputLedger = { - schemaVersion: 1, - inputIds: [...inputIds], - }; - if (this.options.writeAcceptedLedger) { - await this.options.writeAcceptedLedger(file, structuredClone(ledger)); - return; - } - try { - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - await fs.writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await fs.rename(temporary, file); - } finally { - await fs.rm(temporary, { force: true }).catch(() => {}); - } - } - - private async recordAcceptedInput( - state: PersistedPlannerState, - inputId: string, - ): Promise { - const sessionId = state.metadata.identity.sessionId; - const accepted = await this.acceptedInputIds(sessionId); - if (accepted === null) { - throw new Error("planner input acceptance ledger unavailable"); - } - // IDs whose queue entries were already durably removed are stale cleanup - // residue and can be compacted. The active FIFO is bounded by the request - // body limit, and only its IDs are retained here (never input content). - const queuedIds = new Set(state.inputs.map((input) => input.id)); - const retained = [...accepted].filter((id) => queuedIds.has(id)); - if (!retained.includes(inputId)) retained.push(inputId); - await this.writeAcceptedInputIds(sessionId, retained); - } - - private async reconcileAcceptedInputs( - state: PersistedPlannerState, - ): Promise { - const sessionId = state.metadata.identity.sessionId; - const accepted = await this.acceptedInputIds(sessionId); - if (accepted === null || accepted.size === 0) return state; - const remaining = state.inputs.filter((input) => !accepted.has(input.id)); - if (remaining.length === state.inputs.length) return state; - const reconciled: PersistedPlannerState = { - ...structuredClone(state), - inputs: remaining, - dispatchingInputId: - state.dispatchingInputId && accepted.has(state.dispatchingInputId) - ? null - : state.dispatchingInputId, - metadata: { - ...structuredClone(state.metadata), - queuedInputIds: remaining.map((input) => input.id), - }, - }; - await this.persist(sessionId, reconciled); - // The queue commit is now authoritative. Ledger cleanup is best effort: - // stale accepted IDs are harmless and are compacted on the next accept. - await this.writeAcceptedInputIds( - sessionId, - [...accepted].filter((id) => remaining.some((input) => input.id === id)), - ).catch(() => {}); - return reconciled; - } - - private async resolveUncertainDispatch( - state: PersistedPlannerState, - ): Promise { - const inputId = state.dispatchingInputId; - if (inputId === null || state.inputs[0]?.id !== inputId) return state; - const remaining = state.inputs.slice(1); - const resolved: PersistedPlannerState = { - ...structuredClone(state), - inputs: remaining, - dispatchingInputId: null, - metadata: { - ...structuredClone(state.metadata), - queuedInputIds: remaining.map((input) => input.id), - }, - }; - await this.persist(state.metadata.identity.sessionId, resolved); - this.emit({ - name: "planner_session.input_delivery_uncertain", - projectId: state.metadata.identity.projectId, - sessionId: state.metadata.identity.sessionId, - inputId, - errorCode: "delivery_uncertain", - queueDepth: remaining.length, - }); - // There was no readable acceptance proof for this ID. Any stale ledger is - // cleanup residue or was quarantined; reset it after the queue resolution. - await this.writeAcceptedInputIds( - state.metadata.identity.sessionId, - [], - ).catch(() => {}); - return resolved; - } - - private clearTimer(sessionId: string, key?: string): void { - const timer = this.timers.get(sessionId); - if (!timer || (key !== undefined && timer.key !== key)) return; - clearTimeout(timer.handle); - this.timers.delete(sessionId); - } - - private armTimer(sessionId: string, key: "pending" | string): void { - if (this.timers.get(sessionId)?.key === key) return; - this.clearTimer(sessionId); - const handle = setTimeout(() => { - void this.fail( - sessionId, - key, - key === "pending" ? "session_not_ready" : "delivery_timeout", - true, - ).catch(() => { - // Timer callbacks have no request boundary to receive a rejection. - // persist() already projects/emits `persistence_failed` wherever one - // durable store remains; log only a fixed local classification here, - // never the provider/storage error or planner content. - console.error( - "[harness] planner greeting timeout transition failed: persistence_failed", - ); - }); - }, this.deliveryTimeoutMs); - handle.unref?.(); - this.timers.set(sessionId, { key, handle }); - } - - private retireAttemptCorrelation(sessionId: string, attemptId: string): void { - const expected = this.expected.get(sessionId); - if (expected) { - this.expected.set( - sessionId, - expected.map((entry) => - entry.kind === "greeting" && entry.id === attemptId - ? { ...entry, retired: true } - : entry, - ), - ); - } - const observed = this.observedAttempts.get(sessionId); - if (observed) { - this.observedAttempts.set( - sessionId, - observed.map((entry) => - entry?.id === attemptId ? { ...entry, retired: true } : entry, - ), - ); - } - } - - private removeExpectedGreeting(sessionId: string, attemptId: string): void { - const expected = this.expected.get(sessionId); - if (!expected) return; - const remaining = expected.filter( - (entry) => !(entry.kind === "greeting" && entry.id === attemptId), - ); - if (remaining.length === 0) this.expected.delete(sessionId); - else this.expected.set(sessionId, remaining); - } - - private clearCorrelation(sessionId: string): void { - this.expected.delete(sessionId); - this.observedAttempts.delete(sessionId); - this.correlationOverflow.delete(sessionId); - } - - private async persist( - sessionId: string, - state: PersistedPlannerState, - ): Promise { - try { - await this.writeState(this.file(sessionId), state); - } catch { - if (!isTerminal(state.metadata)) { - const attemptId = - state.metadata.greeting.status === "generating" - ? state.metadata.greeting.attemptId - : undefined; - this.clearTimer(sessionId); - state.metadata.greeting = { - status: "failed", - retryable: true, - errorCode: "persistence_failed", - }; - // At least one of the two stores may still be available. Keep the - // bounded classification wherever possible; never persist raw errors. - let fallbackCommitted = false; - try { - await this.options.sessionManager.setPlanningMetadata( - sessionId, - state.metadata, - ); - fallbackCommitted = true; - } catch { - // The primary queue fallback below may still retain the bounded - // terminal classification. - } - try { - await this.writeState(this.file(sessionId), state); - fallbackCommitted = true; - } catch { - // If sessions.json committed, mergeRegistration treats that terminal - // projection as authoritative on restart. Only a total two-store - // outage retains the last committed cache. - } - if (fallbackCommitted) this.states.set(sessionId, structuredClone(state)); - this.emit({ - name: "planner_greeting.failed", - projectId: state.metadata.identity.projectId, - sessionId, - ...(attemptId ? { attemptId } : {}), - errorCode: "persistence_failed", - retryable: true, - queueDepth: state.inputs.length, - }); - } - throw new Error("planner state persistence failed"); - } - - // The queue file contains the full coordinator state and is authoritative. - // Publish its clone only after that primary write commits: a transient - // failure before this point must not leave a phantom dispatch intent in - // memory. SessionManager's sessions.json metadata is a UI/list projection, - // not a second commit prerequisite. If that projection write fails after - // the queue commit, aborting here would strand a durable pre-PTY intent - // that restart must conservatively drop even though submitInput was never - // called. Keep dispatch moving and retry the projection on every later - // transition/registration instead. - this.states.set(sessionId, structuredClone(state)); - await this.options.sessionManager - .setPlanningMetadata(sessionId, state.metadata) - .catch(() => {}); - } - - /** - * Merge the two durable stores under a single serialized registration CAS. - * Queue-file inputs are authoritative. A terminal manager greeting is newer - * than a non-terminal queue greeting (resume suppression), while a terminal - * queue greeting is newer than a stale non-terminal manager snapshot. - */ - private mergeRegistration( - state: PersistedPlannerState, - session: HarnessSession, - ): void { - if (!session.planning) return; - const managerTerminal = isTerminal(session.planning); - const queueTerminal = isTerminal(state.metadata); - if (managerTerminal && !queueTerminal) { - state.metadata.greeting = structuredClone(session.planning.greeting); - } - state.metadata.queuedInputIds = state.inputs.map((input) => input.id); - } - - private async retireHandoffPredecessor( - predecessor: HarnessSession, - source?: PersistedPlannerState, - ): Promise { - const predecessorId = predecessor.id; - const retired: PersistedPlannerState | undefined = source - ? { - ...structuredClone(source), - inputs: [], - dispatchingInputId: null, - metadata: { - ...structuredClone(source.metadata), - queuedInputIds: [], - }, - } - : undefined; - if (retired) this.states.set(predecessorId, retired); - const metadata = - retired?.metadata ?? - (predecessor.planning - ? { ...structuredClone(predecessor.planning), queuedInputIds: [] } - : undefined); - if (metadata) { - await this.options.sessionManager - .setPlanningMetadata(predecessorId, metadata) - .catch(() => {}); - } - this.clearTimer(predecessorId); - this.clearCorrelation(predecessorId); - this.registeredSessions.delete(predecessorId); - this.retiredSessions.add(predecessorId); - } - - /** Atomically move a predecessor's entire coordinator directory into its - * rehydrated replacement. There is never a point with two durable FIFO - * copies: before rename only the predecessor exists; after rename only the - * exact successor exists. The embedded identity rewrite is best effort and - * recoverable through `adoptRehydratedState` after a crash. Accepted entries - * are removed and unresolved dispatch intent is classified uncertain before - * the move, so neither can become replayable under the successor. */ - private async handoffRehydratedInputs( - session: HarnessSession, - replacementState: PersistedPlannerState, - ): Promise<{ state: PersistedPlannerState; moved: boolean }> { - const predecessorId = session.rehydratedFrom; - if (!predecessorId || predecessorId === session.id) { - return { state: replacementState, moved: false }; - } - const predecessor = this.options.sessionManager.get(predecessorId); - const sourceIdentity = predecessor?.planning?.identity; - const targetIdentity = session.planning?.identity; - if ( - !predecessor || - !sourceIdentity || - !targetIdentity || - sourceIdentity.role !== "map-planner" || - targetIdentity.role !== "map-planner" || - sourceIdentity.projectId !== targetIdentity.projectId || - sourceIdentity.userId !== targetIdentity.userId - ) { - return { state: replacementState, moved: false }; - } - - return this.serialize(predecessorId, async () => { - // A canonical or adoptable target file proves a prior atomic move - // already committed. On a later boot the old HarnessSession may register - // first and recreate an empty source queue; never rename that directory - // over the authoritative target. A non-empty second source is an - // impossible/conflicting dual owner and fails closed. - try { - await fs.access(this.file(session.id)); - let recreatedSource: PersistedPlannerState | undefined; - try { - await fs.access(this.file(predecessorId)); - recreatedSource = await this.load(predecessor); - recreatedSource = await this.reconcileAcceptedInputs(recreatedSource); - if (recreatedSource.dispatchingInputId !== null) { - recreatedSource = await this.resolveUncertainDispatch(recreatedSource); - } - if (recreatedSource.inputs.length > 0) { - throw new Error("conflicting planner handoff queues"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - await this.retireHandoffPredecessor(predecessor, recreatedSource); - return { state: replacementState, moved: true }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - - try { - await fs.access(this.file(predecessorId)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { - state: replacementState, - moved: this.adoptedSessions.has(session.id), - }; - } - throw error; - } - let source = await this.load(predecessor); - source = await this.reconcileAcceptedInputs(source); - if (source.dispatchingInputId !== null) { - source = await this.resolveUncertainDispatch(source); - } - - const transferred = source.inputs.map((input) => ({ - ...structuredClone(input), - sessionId: session.id, - })); - const transferredIds = new Set(transferred.map((input) => input.id)); - const mergedInputs = [ - ...transferred, - ...replacementState.inputs.filter( - (input) => !transferredIds.has(input.id), - ), - ]; - const replacement: PersistedPlannerState = { - ...structuredClone(source), - inputs: mergedInputs, - dispatchingInputId: null, - metadata: { - ...structuredClone(source.metadata), - identity: structuredClone(targetIdentity), - queuedInputIds: mergedInputs.map((input) => input.id), - }, - }; - const sourceDirectory = path.dirname(this.file(predecessorId)); - const targetDirectory = path.dirname(this.file(session.id)); - const move = this.options.moveStateDirectory ?? fs.rename; - await move(sourceDirectory, targetDirectory); - - // The rename above is the only ownership commit. Publish the re-keyed - // cache immediately; rewriting the moved file is merely canonicalization - // because a crash can always adopt its predecessor identity in place. - this.states.set(session.id, structuredClone(replacement)); - this.states.delete(predecessorId); - try { - await this.writeState(this.file(session.id), replacement); - this.adoptedSessions.delete(session.id); - } catch { - this.adoptedSessions.add(session.id); - } - await this.writeAcceptedInputIds(session.id, []).catch(() => {}); - - await this.retireHandoffPredecessor(predecessor, source); - return { state: replacement, moved: true }; - }); - } - - async register( - session: HarnessSession, - context: PlannerRegistrationContext, - ): Promise { - if (!session.planning) return; - let shouldStart = false; - let shouldDrain = false; - await this.serialize(session.id, async () => { - const cached = this.states.has(session.id); - let state = await this.load(session, context.emptyProject); - let handoffMoved = false; - // A crash can occur after the replacement queue commit but before the - // predecessor retirement commit. `rehydratedFrom` is persisted on the - // HarnessSession, so repeat this idempotent handoff during process boot - // as well as the original rehydration callback. - if (session.rehydratedFrom) { - const handoff = await this.handoffRehydratedInputs(session, state); - state = handoff.state; - handoffMoved = handoff.moved; - } - this.mergeRegistration(state, session); - - // Only a process-boot load proves an in-flight dispatch was abandoned. - // Live re-registration is idempotent and must not fail its active turn. - if ( - context.mode === "boot" && - !cached && - state.metadata.greeting.status === "generating" - ) { - const attemptId = state.metadata.greeting.attemptId; - state.metadata.greeting = state.inputs.length - ? { status: "skipped", reason: "user-proceeded" } - : { - status: "failed", - retryable: true, - errorCode: "delivery_timeout", - }; - if (state.inputs.length) { - this.emit({ - name: "planner_greeting.skipped", - projectId: state.metadata.identity.projectId, - sessionId: session.id, - attemptId, - reason: "user-proceeded", - queueDepth: state.inputs.length, - }); - } else { - this.emit({ - name: "planner_greeting.failed", - projectId: state.metadata.identity.projectId, - sessionId: session.id, - attemptId, - errorCode: "delivery_timeout", - retryable: true, - queueDepth: 0, - }); - } - } - if (handoffMoved) { - // The directory rename already durably committed FIFO ownership. A - // failed canonical rewrite must not reject the exact successor and let - // a later open mint a second owner; future pre-PTY transitions retry - // the normal authoritative queue write. - this.states.set(session.id, structuredClone(state)); - try { - await this.writeState(this.file(session.id), state); - this.adoptedSessions.delete(session.id); - } catch { - this.adoptedSessions.add(session.id); - } - await this.options.sessionManager - .setPlanningMetadata(session.id, state.metadata) - .catch(() => {}); - } else { - await this.persist(session.id, state); - } - this.registeredSessions.add(session.id); - if (state.metadata.greeting.status === "pending") { - const allowed = await this.canDispatch(session); - if ( - session.ready && - session.status === "running" && - allowed - ) shouldStart = true; - else if (allowed) this.armTimer(session.id, "pending"); - } else if (isTerminal(state.metadata)) { - shouldDrain = - session.ready && - session.status === "running" && - (await this.canDispatch(session)); - } - }); - if (shouldStart) await this.startGreeting(session.id, false); - else if (shouldDrain) await this.drainSession(session.id); - } - - async onSessionStatus(session: HarnessSession): Promise { - if (!session.planning) return; - if ( - !this.registeredSessions.has(session.id) || - this.retiredSessions.has(session.id) - ) return; - if ( - session.ready && - session.status === "running" && - (await this.canDispatch(session)) - ) { - this.clearTimer(session.id, "pending"); - const state = this.states.get(session.id); - if (state && isTerminal(state.metadata)) await this.drainSession(session.id); - else await this.startGreeting(session.id, false); - return; - } - if (session.status === "exited") { - const timer = this.timers.get(session.id); - this.clearTimer(session.id); - this.expected.delete(session.id); - this.observedAttempts.delete(session.id); - this.correlationOverflow.delete(session.id); - await this.fail( - session.id, - timer?.key ?? "pending", - "session_exited", - false, - ); - } - } - - private async startGreeting(sessionId: string, retry: boolean): Promise { - await this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) return; - const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) return; - const state = await this.load(session); - if (!(await this.canDispatch(session))) { - if (retry) throw new PlannerDispatchForbiddenError(); - if (state.metadata.greeting.status === "pending") { - await this.setFailure(state, "pending", "session_exited", false); - } - return; - } - if (retry) { - if ( - state.metadata.greeting.status !== "failed" || - !state.metadata.greeting.retryable || - state.inputs.length > 0 || - state.retryCount >= MAX_RETRIES - ) { - throw new PlannerGreetingRetryUnavailableError(); - } - state.retryCount += 1; - } else if (state.metadata.greeting.status !== "pending") { - return; - } - this.clearTimer(sessionId, "pending"); - const attemptId = this.generateId(); - state.metadata.greeting = { status: "generating", attemptId }; - await this.persist(sessionId, state); - this.emit({ - name: retry ? "planner_greeting.retried" : "planner_greeting.attempted", - projectId: state.metadata.identity.projectId, - sessionId, - attemptId, - queueDepth: state.inputs.length, - }); - const prompt = plannerGreetingPrompt(state.emptyProject, attemptId); - if (!(await this.canDispatch(session))) { - await this.setFailure(state, attemptId, "session_exited", false); - if (retry) throw new PlannerDispatchForbiddenError(); - return; - } - // Register before crossing the PTY boundary. A prompt hook may arrive - // immediately after the write, before submitInput's delayed Enter has - // resolved; registering afterward loses the only safe correlation. - const queue = this.expected.get(sessionId) ?? []; - queue.push({ - kind: "greeting", - id: attemptId, - text: prompt, - retired: false, - }); - this.expected.set(sessionId, queue); - try { - const accepted = await this.options.sessionManager.submitInput( - sessionId, - prompt, - true, - () => this.canDispatch(session), - ); - if (!accepted) { - // A false return proves the prompt did not cross the PTY boundary. - this.removeExpectedGreeting(sessionId, attemptId); - await this.setFailure(state, attemptId, "session_exited", false); - return; - } - this.armTimer(sessionId, attemptId); - } catch (error) { - if ( - error instanceof SessionNotReadyError || - (error instanceof SessionInputGuardRejectedError && !error.staged) - ) { - // Both cases prove absence at the PTY boundary. A guard rejection - // after staging is intentionally retained/retired as uncertain. - this.removeExpectedGreeting(sessionId, attemptId); - } - await this.setFailure( - state, - attemptId, - error instanceof SessionNotReadyError - ? "session_not_ready" - : error instanceof SessionInputGuardRejectedError - ? "session_exited" - : "injection_failed", - !(error instanceof SessionInputGuardRejectedError), - ); - if (retry && error instanceof SessionInputGuardRejectedError) { - throw new PlannerDispatchForbiddenError(); - } - } - }); - } - - private async setFailure( - state: PersistedPlannerState, - expectedKey: "pending" | string, - errorCode: PlannerGreetingErrorCode, - retryable: boolean, - ): Promise { - const greeting = state.metadata.greeting; - const matches = - (expectedKey === "pending" && greeting.status === "pending") || - (greeting.status === "generating" && greeting.attemptId === expectedKey); - if (!matches) return; - const sessionId = state.metadata.identity.sessionId; - const attemptId = greeting.status === "generating" ? greeting.attemptId : undefined; - this.clearTimer(sessionId, expectedKey); - if (attemptId) this.retireAttemptCorrelation(sessionId, attemptId); - if (state.inputs.length > 0) { - state.metadata.greeting = { status: "skipped", reason: "user-proceeded" }; - await this.persist(sessionId, state); - this.clearCorrelation(sessionId); - this.emit({ - name: "planner_greeting.skipped", - projectId: state.metadata.identity.projectId, - sessionId, - ...(attemptId ? { attemptId } : {}), - reason: "user-proceeded", - queueDepth: state.inputs.length, - }); - await this.drain(state); - return; - } - state.metadata.greeting = { status: "failed", retryable, errorCode }; - await this.persist(sessionId, state); - if (!retryable) this.clearCorrelation(sessionId); - this.emit({ - name: "planner_greeting.failed", - projectId: state.metadata.identity.projectId, - sessionId, - ...(attemptId ? { attemptId } : {}), - errorCode, - retryable, - queueDepth: 0, - }); - } - - private async fail( - sessionId: string, - expectedKey: "pending" | string, - errorCode: PlannerGreetingErrorCode, - retryable: boolean, - ): Promise { - await this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) return; - const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) return; - const state = await this.load(session); - await this.setFailure(state, expectedKey, errorCode, retryable); - }); - } - - retry(sessionId: string): Promise { - return this.startGreeting(sessionId, true); - } - - async enqueue(sessionId: string, text: string): Promise { - return this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) { - throw new PlannerDispatchForbiddenError(); - } - const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) throw new Error("planner session not found"); - if (!(await this.canDispatch(session))) { - throw new PlannerDispatchForbiddenError(); - } - const state = await this.load(session); - const input: PlannerQueuedInput = { - id: this.generateId(), - sessionId, - text, - acceptedAt: this.now(), - }; - state.inputs.push(input); - state.metadata.queuedInputIds.push(input.id); - if (state.metadata.greeting.status === "failed") { - state.metadata.greeting = { status: "skipped", reason: "user-proceeded" }; - this.emit({ - name: "planner_greeting.skipped", - projectId: state.metadata.identity.projectId, - sessionId, - reason: "user-proceeded", - queueDepth: state.inputs.length, - }); - } - await this.persist(sessionId, state); - if (isTerminal(state.metadata)) await this.drain(state, true); - // drain() advances through immutable queue-state clones. Return the - // latest authoritative projection rather than the pre-drain object so a - // 202 response never reports IDs that were already durably dequeued. - return structuredClone(this.states.get(sessionId)?.metadata ?? state.metadata); - }); - } - - private async drainSession(sessionId: string): Promise { - await this.serialize(sessionId, async () => { - if (this.retiredSessions.has(sessionId)) return; - const session = this.options.sessionManager.get(sessionId); - if (!session?.planning) return; - const state = await this.load(session); - if (isTerminal(state.metadata)) await this.drain(state); - }); - } - - private async drain( - initialState: PersistedPlannerState, - throwOnForbidden = false, - ): Promise { - let state: PersistedPlannerState; - try { - // An accepted-input ledger is the commit/ack boundary. If the prior - // process reached the PTY but failed to rewrite the FIFO, finish that - // dequeue without submitting the prompt again. - state = await this.reconcileAcceptedInputs(initialState); - } catch { - return; - } - while (state.inputs.length > 0) { - const input = state.inputs[0]!; - const session = this.options.sessionManager.get(input.sessionId); - if ( - !session?.ready || - session.status !== "running" - ) return; - if (!(await this.canDispatch(session))) { - if (throwOnForbidden) throw new PlannerDispatchForbiddenError(); - return; - } - - // A durable intent without a durable acceptance acknowledgement is - // irreducibly ambiguous across a crash. Never guess by replaying it: a - // later accepted ledger can complete the dequeue, while automatic replay - // could duplicate a user message already written to the PTY. - if (state.dispatchingInputId !== null) { - try { - state = await this.resolveUncertainDispatch(state); - } catch { - return; - } - continue; - } - - const prepared: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: input.id, - }; - try { - await this.persist(input.sessionId, prepared); - } catch { - // No external side effect occurred before the intent commit. - return; - } - state = prepared; - if (!(await this.canDispatch(session))) { - const rollback: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: null, - }; - await this.persist(input.sessionId, rollback).catch(() => {}); - if (throwOnForbidden) throw new PlannerDispatchForbiddenError(); - return; - } - let accepted = false; - try { - accepted = await this.options.sessionManager.submitInput( - input.sessionId, - input.text, - true, - () => this.canDispatch(session), - ); - } catch (error) { - const rollback: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: null, - }; - await this.persist(input.sessionId, rollback).catch(() => {}); - if ( - throwOnForbidden && - error instanceof SessionInputGuardRejectedError - ) { - throw new PlannerDispatchForbiddenError(); - } - return; - } - if (!accepted) { - const rollback: PersistedPlannerState = { - ...structuredClone(state), - dispatchingInputId: null, - }; - await this.persist(input.sessionId, rollback).catch(() => {}); - return; - } - // Register local correlation only after acceptance. The accepted ledger - // then commits the external side effect before the FIFO is rewritten. - const queue = this.expected.get(input.sessionId) ?? []; - queue.push({ kind: "user", id: input.id, text: input.text }); - this.expected.set(input.sessionId, queue); - try { - await this.recordAcceptedInput(state, input.id); - } catch { - // The durable intent remains unresolved and will not be replayed after - // restart. True PTY exactly-once is impossible without this ack. - return; - } - - const committed: PersistedPlannerState = { - ...structuredClone(state), - inputs: state.inputs.slice(1), - dispatchingInputId: null, - metadata: { - ...structuredClone(state.metadata), - queuedInputIds: state.metadata.queuedInputIds.slice(1), - }, - }; - try { - await this.persist(input.sessionId, committed); - } catch { - // The accepted ledger is durable. A restart will finish this exact - // dequeue without submitting the input twice. - return; - } - state = committed; - await this.writeAcceptedInputIds(input.sessionId, []).catch(() => {}); - } - } - - /** Add local-only correlation without removing transcript content. */ - decorateLocalEvent(event: AnalyticsEvent): AnalyticsEvent { - if (this.retiredSessions.has(event.harnessSessionId)) return event; - const session = this.options.sessionManager.get(event.harnessSessionId); - if (!session?.planning || event.type !== "prompt.submitted") return event; - const prompt = typeof event.payload.prompt === "string" ? event.payload.prompt : ""; - const queue = this.expected.get(event.harnessSessionId) ?? []; - const index = queue.findIndex((entry) => entry.text === prompt); - const [match] = index < 0 ? [] : queue.splice(index, 1); - if (queue.length === 0) this.expected.delete(event.harnessSessionId); - const observed = this.observedAttempts.get(event.harnessSessionId) ?? []; - // One barrier per observed planner prompt, including unmatched/user - // prompts. turn.completed has no attempt token, so skipping those barriers - // would let their completion shift a later greeting attempt instead. - if (observed.length >= 256) { - this.clearCorrelation(event.harnessSessionId); - this.correlationOverflow.add(event.harnessSessionId); - } else { - observed.push( - match?.kind === "greeting" - ? { id: match.id, retired: match.retired === true } - : null, - ); - this.observedAttempts.set(event.harnessSessionId, observed); - } - if (!match) return event; - return { - ...event, - payload: { - ...event.payload, - plannerOrigin: match.kind === "greeting" ? "infrastructure" : "user", - ...(match.kind === "greeting" - ? { plannerAttemptId: match.id } - : { plannerInputId: match.id }), - }, - }; - } - - /** Product telemetry receives no planning content, paths, or provider text. */ - redactForTelemetry(event: AnalyticsEvent): AnalyticsEvent { - const session = this.options.sessionManager.get(event.harnessSessionId); - return session?.planning - ? { - ...event, - // The normalized hook envelope is attacker-controlled too: every - // hook can supply `payload.session_id`. The harness session ID is the - // server-owned planner correlation key, so provider identity is not - // needed in remote planner telemetry at all. - agentSessionId: null, - payload: telemetryPayload(event), - } - : event; - } - - async onEventPersisted(event: AnalyticsEvent): Promise { - if (event.type !== "turn.completed") return; - await this.serialize(event.harnessSessionId, async () => { - if (this.retiredSessions.has(event.harnessSessionId)) return; - // Consume exactly one prompt barrier for every completion before looking - // at lifecycle state. In particular, a late completion while attempt 1 - // is failed must not remain queued to satisfy a later retry. - const observed = this.observedAttempts.get(event.harnessSessionId); - const completedAttempt = observed?.shift(); - if (observed?.length === 0) { - this.observedAttempts.delete(event.harnessSessionId); - } - const session = this.options.sessionManager.get(event.harnessSessionId); - if (!session?.planning) return; - const state = await this.load(session); - if (this.correlationOverflow.delete(event.harnessSessionId)) { - if (state.metadata.greeting.status === "generating") { - await this.setFailure( - state, - state.metadata.greeting.attemptId, - "model_turn_failed", - true, - ); - } - return; - } - if (state.metadata.greeting.status !== "generating") return; - const attemptId = state.metadata.greeting.attemptId; - if (!completedAttempt) return; - // Stop/turn.completed carries no attempt token. Consume correlations in - // prompt-observation order: a retired older turn is a tombstone, never - // evidence that the currently generating retry completed. - if (completedAttempt.retired || completedAttempt.id !== attemptId) return; - const text = event.payload.assistantText; - if (typeof text !== "string" || text.trim() === "") { - await this.setFailure(state, attemptId, "model_turn_failed", true); - return; - } - this.clearTimer(event.harnessSessionId, attemptId); - state.metadata.greeting = { - status: "delivered", - messageId: event.eventId, - }; - await this.persist(event.harnessSessionId, state); - this.clearCorrelation(event.harnessSessionId); - this.emit({ - name: "planner_greeting.delivered", - projectId: state.metadata.identity.projectId, - sessionId: event.harnessSessionId, - attemptId, - queueDepth: state.inputs.length, - }); - await this.drain(state); - }); - } -} diff --git a/packages/harness/src/core/planning-session.test.ts b/packages/harness/src/core/planning-session.test.ts deleted file mode 100644 index 6976fcbad..000000000 --- a/packages/harness/src/core/planning-session.test.ts +++ /dev/null @@ -1,817 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { AGENT_MAP_PLANNER_SESSION_START_MESSAGE } from "../profiles/agent-map-planner.js"; -import type { AgentMapWorkspaceState } from "../shared/agent-map.js"; -import type { HarnessSession, SessionRecord } from "../shared/types.js"; -import type { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; -import { - buildFocusedPlannerContext, - isPlannerDispatchAuthorized, - localPlanningPrincipal, - PlanningSessionError, - PlanningSessionService, -} from "./planning-session.js"; -import type { SessionManager } from "./session-manager.js"; -import { PlannerGreetingCoordinator } from "./planner-greeting.js"; -import type { - StudioProjectCatalog, - StudioProjectIdentity, -} from "./studio-project-catalog.js"; - -const projectId = "project_00000000-0000-4000-8000-000000000001"; - -const project: StudioProjectIdentity = { - projectId, - identityVersion: 1, - displayName: "Private research", - rootBindings: [ - { - id: "root_00000000-0000-4000-8000-000000000001", - repositoryId: "repo-private", - localRootRef: "/Users/private/customer-secret-project", - status: "active", - }, - ], - legacyWorkspaceKeys: ["private-workspace-key"], - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", -}; - -const workspace: AgentMapWorkspaceState = { - projectId, - schemaVersion: 1, - recordVersion: 1, - confirmedRevisionId: null, - activeProposalId: null, - projectBuildPlanId: null, - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", -}; - -function session( - id: string, - overrides: Partial = {}, -): HarnessSession { - return { - id, - agentSessionId: null, - harness: "codex", - cwd: project.rootBindings[0]!.localRootRef, - title: "Private research", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: false, - planning: { - identity: { projectId, sessionId: id, userId: "user-1", role: "map-planner" }, - greeting: { status: "pending" }, - queuedInputIds: [], - }, - ...overrides, - }; -} - -function fixture( - existing: HarnessSession[] = [], - initialProject: StudioProjectIdentity = project, - initialWorkspace: AgentMapWorkspaceState = workspace, -) { - let next = 0; - let resolvedProject = initialProject; - let currentUserId: string | null = "user-1"; - const contexts: string[] = []; - const sessionStartMessages: Array = []; - const created: HarnessSession[] = []; - const create = vi.fn(async (request, trusted) => { - const id = `new-${++next}`; - contexts.push(trusted.promptAppendix(id)); - sessionStartMessages.push(trusted.sessionStartSystemMessage?.(id) ?? null); - const value = session(id, { - harness: request.harness, - cwd: request.cwd, - planning: trusted.planning(id), - rehydratedFrom: - trusted.handoffFromSessionId ?? request.rehydrateFrom ?? null, - }); - created.push(value); - return value; - }); - const resume = vi.fn(async (id, trusted) => { - const value = existing.find((candidate) => candidate.id === id)!; - value.planning = structuredClone(trusted.planning); - contexts.push(trusted.promptAppendix); - return value; - }); - const kill = vi.fn(async (id: string) => { - const value = [...existing, ...created].find( - (candidate) => candidate.id === id, - ); - if (value) value.status = "exited"; - return Boolean(value); - }); - const manager = { - create, - resume, - list: () => [...existing, ...created], - isLive: (id: string) => - [...existing, ...created].some( - (candidate) => candidate.id === id && candidate.status === "running", - ), - get: (id: string) => [...existing, ...created].find((candidate) => candidate.id === id), - setPlanningMetadata: async ( - id: string, - metadata: NonNullable, - ) => { - const value = [...existing, ...created].find((candidate) => candidate.id === id); - if (value) value.planning = structuredClone(metadata); - }, - submitInput: vi.fn(async () => true), - kill, - } as unknown as SessionManager; - const service = new PlanningSessionService({ - catalog: { - resolveIdentity: async (id: string) => - id === projectId ? resolvedProject : null, - } as unknown as StudioProjectCatalog, - workspaceStore: { - readOrCreate: async () => initialWorkspace, - } as unknown as AgentMapWorkspaceStore, - sessionManager: manager, - readRecord: async () => null, - userId: "user-1", - currentUserId: () => currentUserId, - machineId: "machine-1", - defaultHarness: "codex", - }); - return { - service, - create, - resume, - kill, - contexts, - sessionStartMessages, - manager, - created, - setProject: (value: StudioProjectIdentity) => { - resolvedProject = value; - }, - setUserId: (value: string | null) => { - currentUserId = value; - }, - }; -} - -describe("planner session context and identity", () => { - it("uses the authenticated user or a stable machine-local principal", () => { - expect(localPlanningPrincipal("user-1", "machine-1")).toBe("user-1"); - expect(localPlanningPrincipal(null, "machine-1")).toBe("local:machine-1"); - }); - - it("serializes only allowlisted focused context and never a local path", () => { - const context = buildFocusedPlannerContext({ - project, - workspace, - sessionId: "session-1", - userId: "user-1", - onboardOnFirstResponse: true, - }); - expect(context).toContain(projectId); - expect(context).toContain(project.rootBindings[0]!.id); - expect(context).toContain('"role":"map-planner"'); - expect(context).toContain('"empty":true'); - expect(context).toContain("In your first response, briefly explain"); - expect(context).not.toContain("/Users/private"); - expect(context).not.toContain("private-workspace-key"); - expect(context).not.toContain("localRootRef"); - expect(context).not.toContain("prompt"); - expect(context.length).toBeLessThan(16_384); - }); - - it("bounds revision summaries, proposal/build status, and warnings", () => { - const populated: AgentMapWorkspaceState = { - ...workspace, - confirmedRevisionId: "revision-1", - activeProposalId: "proposal-1", - projectBuildPlanId: "build-plan-1", - }; - const context = buildFocusedPlannerContext({ - project, - workspace: populated, - sessionId: "session-1", - userId: "user-1", - onboardOnFirstResponse: false, - details: { - confirmedRevision: { - digest: "d".repeat(2_000), - summaries: Array.from({ length: 80 }, (_, index) => - `node-${index}-${"s".repeat(400)}`, - ), - }, - activeProposal: { status: "draft", summary: "proposal summary" }, - projectBuildPlan: { status: "pending", summary: "build summary" }, - warnings: Array.from({ length: 40 }, (_, index) => `warning-${index}`), - }, - }); - const parsed = JSON.parse(context.split("\n")[2]!) as { - project: { - confirmedRevision: { digest: string; summaries: string[] }; - activeProposal: { status: string }; - projectBuildPlan: { status: string }; - warnings: string[]; - }; - }; - - expect(parsed.project.confirmedRevision.digest).toHaveLength(512); - expect(parsed.project.confirmedRevision.summaries).toHaveLength(32); - expect(parsed.project.confirmedRevision.summaries[0]!.length).toBeLessThanOrEqual(256); - expect(parsed.project.activeProposal.status).toBe("draft"); - expect(parsed.project.projectBuildPlan.status).toBe("pending"); - expect(parsed.project.warnings).toHaveLength(16); - expect(context.length).toBeLessThan(16_384); - expect(context).not.toContain(project.rootBindings[0]!.localRootRef); - }); - - it("rechecks the live principal after an awaited dispatch binding lookup", async () => { - let userId = "user-1"; - let resolveProject!: (value: StudioProjectIdentity | null) => void; - const authorization = isPlannerDispatchAuthorized({ - session: session("planner-dispatch"), - currentPrincipal: () => userId, - resolveProject: () => - new Promise((resolve) => { - resolveProject = resolve; - }), - }); - - await Promise.resolve(); - userId = "user-b"; - resolveProject(project); - - await expect(authorization).resolves.toBe(false); - }); -}); - -describe("PlanningSessionService", () => { - it("always creates a new, server-scoped planner for explicit fresh", async () => { - const { service, create, contexts, sessionStartMessages } = fixture(); - const first = await service.open(projectId, { mode: "fresh" }); - const second = await service.open(projectId, { mode: "fresh" }); - - expect(first.resolution).toBe("created"); - expect(second.session.id).not.toBe(first.session.id); - expect(create).toHaveBeenCalledTimes(2); - expect(first.session.planning).toEqual({ - identity: { - projectId, - sessionId: first.session.id, - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [], - }); - expect(contexts.every((value) => !value.includes(project.rootBindings[0]!.localRootRef))).toBe(true); - expect(contexts).toEqual([ - expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", - ), - expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", - ), - ]); - expect(contexts.join("\n")).not.toContain( - "This is a private Agent Studio control turn", - ); - expect(sessionStartMessages).toEqual([null, null]); - }); - - it("uses native Claude startup orientation without repeating it in turn one", async () => { - const { service, contexts, sessionStartMessages } = fixture(); - - await service.open(projectId, { - mode: "fresh", - harness: "claude-code", - }); - - expect(sessionStartMessages).toEqual([ - AGENT_MAP_PLANNER_SESSION_START_MESSAGE, - ]); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); - }); - - it("does not replay first-time onboarding for an already-planned project", async () => { - const { service, contexts } = fixture([], project, { - ...workspace, - confirmedRevisionId: "revision-1", - }); - - await service.open(projectId, { mode: "fresh" }); - - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); - }); - - it("serializes concurrent resume-or-create so both callers resolve one planner", async () => { - const { service, create } = fixture(); - const [first, second] = await Promise.all([ - service.open(projectId, { mode: "resume-or-create" }), - service.open(projectId, { mode: "resume-or-create" }), - ]); - - expect(create).toHaveBeenCalledTimes(1); - expect(first).toMatchObject({ resolution: "created" }); - expect(second).toMatchObject({ - resolution: "live", - session: { id: first.session.id }, - }); - }); - - it("keeps the latest live owned session and rejects cross-project replay", async () => { - const older = session("older", { - lastActiveAt: "2026-09-01T01:00:00.000Z", - }); - const latest = session("latest", { - lastActiveAt: "2026-09-01T02:00:00.000Z", - }); - const { service, create } = fixture([older, latest]); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).resolves.toMatchObject({ resolution: "live", session: { id: "latest" } }); - expect(create).not.toHaveBeenCalled(); - await expect( - service.requireOwned("other-project", latest.id), - ).rejects.toThrow(PlanningSessionError); - }); - - it("accepts a planner on any current active root, not only the launch root", async () => { - const multiRoot: StudioProjectIdentity = { - ...project, - rootBindings: [ - ...project.rootBindings, - { - id: "root_00000000-0000-4000-8000-000000000002", - repositoryId: "repo-secondary", - localRootRef: "/Users/private/secondary-root", - status: "active", - }, - ], - }; - const secondary = session("secondary", { - cwd: "/Users/private/secondary-root", - }); - const { service, create } = fixture([secondary], multiRoot); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).resolves.toMatchObject({ resolution: "live", session: { id: secondary.id } }); - await expect(service.requireOwned(projectId, secondary.id)).resolves.toBe( - secondary, - ); - expect(create).not.toHaveBeenCalled(); - }); - - it("never returns or resumes a stale-root candidate after a move and rehydrates at the current root", async () => { - const moved: StudioProjectIdentity = { - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-project", - })), - }; - const stale = session("stale-root", { - cwd: project.rootBindings[0]!.localRootRef, - status: "running", - agentSessionId: "old-vendor", - }); - const { service, resume, create } = fixture([stale], moved); - ( - service as unknown as { - options: { readRecord: () => Promise }; - } - ).options.readRecord = async () => ({ turnCount: 1 } as SessionRecord); - - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - - expect(result.resolution).toBe("rehydrated"); - expect(resume).not.toHaveBeenCalled(); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: "/Users/private/moved-project", - rehydrateFrom: stale.id, - }), - expect.any(Object), - ); - }); - - it("re-resolves current bindings for every scoped operation", async () => { - const owned = session("owned-root"); - const { service, setProject } = fixture([owned]); - await expect(service.requireOwned(projectId, owned.id)).resolves.toBe(owned); - - setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-project", - })), - }); - - await expect(service.requireOwned(projectId, owned.id)).rejects.toMatchObject({ - code: "forbidden", - }); - }); - - it("isolates planners across authenticated, local, and replacement principals", async () => { - const accountA = session("account-a"); - const { service, setUserId } = fixture([accountA]); - await expect(service.requireOwned(projectId, accountA.id)).resolves.toBe(accountA); - - setUserId(null); - await expect(service.requireOwned(projectId, accountA.id)).rejects.toMatchObject({ - code: "forbidden", - }); - const local = await service.open(projectId, { mode: "fresh" }); - expect(local.session.planning?.identity.userId).toBe("local:machine-1"); - - setUserId("user-b"); - await expect(service.requireOwned(projectId, local.session.id)).rejects.toMatchObject({ - code: "forbidden", - }); - const accountB = await service.open(projectId, { mode: "fresh" }); - expect(accountB.session.planning?.identity.userId).toBe("user-b"); - }); - - it("kills a newly created planner if principal or binding changes mid-open", async () => { - const principalSwitch = fixture(); - ( - principalSwitch.service as unknown as { - options: { onPlannerSession: () => void }; - } - ).options.onPlannerSession = () => principalSwitch.setUserId(null); - await expect( - principalSwitch.service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(principalSwitch.kill).toHaveBeenCalledWith("new-1"); - - const bindingSwitch = fixture(); - ( - bindingSwitch.service as unknown as { - options: { onPlannerSession: () => void }; - } - ).options.onPlannerSession = () => - bindingSwitch.setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-during-open", - })), - }); - await expect( - bindingSwitch.service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(bindingSwitch.kill).toHaveBeenCalledWith("new-1"); - }); - - it("reattaches focused context and suppresses onboarding on vendor resume", async () => { - const prior = session("resume-me", { - status: "exited", - agentSessionId: "vendor-session", - }); - const { service, resume, contexts } = fixture([prior]); - - const result = await service.open(projectId, { mode: "resume-or-create" }); - - expect(result.resolution).toBe("resumed"); - expect(resume).toHaveBeenCalledTimes(1); - expect(result.session.planning?.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); - expect(contexts[0]).toContain(projectId); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); - expect(contexts[0]).not.toContain(project.rootBindings[0]!.localRootRef); - }); - - it("rehydrates recorded history when vendor resume is unavailable", async () => { - const prior = session("recorded", { - status: "exited", - agentSessionId: "stale-vendor-session", - }); - const { service, resume, create, contexts, sessionStartMessages } = fixture([ - prior, - ]); - resume.mockRejectedValueOnce(new Error("not resumable")); - (service as unknown as { options: { readRecord: () => Promise } }).options.readRecord = - async () => ({ turnCount: 1 } as SessionRecord); - - const result = await service.open(projectId, { mode: "resume-or-create" }); - - expect(result.resolution).toBe("rehydrated"); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ rehydrateFrom: prior.id }), - expect.any(Object), - ); - expect(result.session.planning?.greeting.status).toBe("skipped"); - expect(contexts[0]).not.toContain( - "In your first response, briefly explain", - ); - expect(sessionStartMessages).toEqual([null]); - }); - - it("hands a restarted pre-ready FIFO to a rehydrated planner exactly once and retires the old queue", async () => { - const prior = session("queued-predecessor", { - status: "exited", - ready: false, - agentSessionId: "missing-vendor-history", - }); - const { service, resume, manager } = fixture([prior]); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-handoff-")); - const beforeRestart = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await beforeRestart.register(prior, { - emptyProject: true, - mode: "created", - }); - await beforeRestart.enqueue(prior.id, "queued before readiness"); - - // New coordinator instance is the process-restart boundary. Vendor resume - // fails, so PlanningSessionService creates a replacement with - // rehydratedFrom=prior.id and registration performs the durable handoff. - const afterRestart = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - resume.mockRejectedValueOnce(new Error("vendor history unavailable")); - const options = ( - service as unknown as { - options: { - readRecord: () => Promise; - onPlannerSession: PlanningSessionService["options"]["onPlannerSession"]; - }; - } - ).options; - options.readRecord = async () => ({ turnCount: 1 } as SessionRecord); - options.onPlannerSession = (value, context) => - afterRestart.register(value, context); - - try { - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - expect(result.resolution).toBe("rehydrated"); - result.session.ready = true; - await afterRestart.onSessionStatus(result.session); - - expect(manager.submitInput).toHaveBeenCalledTimes(1); - expect(manager.submitInput).toHaveBeenCalledWith( - result.session.id, - "queued before readiness", - true, - expect.any(Function), - ); - const replacementQueue = JSON.parse( - await fs.readFile( - path.join(root, result.session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - await expect( - fs.access(path.join(root, prior.id, "input-queue.json")), - ).rejects.toMatchObject({ code: "ENOENT" }); - expect(replacementQueue.inputs).toEqual([]); - - // A full later boot registers the historical predecessor first, which - // recreates its empty queue file. Registering the successor must detect - // its already-committed target directory and never rename the recreated - // source over it. - const secondBoot = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await secondBoot.register(prior, { emptyProject: true, mode: "boot" }); - await expect( - secondBoot.register(result.session, { - emptyProject: true, - mode: "boot", - }), - ).resolves.toBeUndefined(); - await secondBoot.onSessionStatus(result.session); - expect(manager.submitInput).toHaveBeenCalledTimes(1); - const durableAfterSecondBoot = JSON.parse( - await fs.readFile( - path.join(root, result.session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - expect(durableAfterSecondBoot.inputs).toEqual([]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it.each(["atomic-move", "canonical-rewrite"] as const)( - "keeps exactly one FIFO successor when the %s boundary fails", - async (failurePoint) => { - const prior = session("handoff-fault-source", { - status: "exited", - ready: false, - agentSessionId: "missing-vendor-history", - }); - const { service, resume, manager } = fixture([prior]); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-handoff-fault-")); - const seed = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - await seed.register(prior, { emptyProject: true, mode: "created" }); - await seed.enqueue(prior.id, "survive handoff fault"); - - let rejectMove = failurePoint === "atomic-move"; - const firstSuccessor = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - moveStateDirectory: async (source, target) => { - if (rejectMove) { - rejectMove = false; - throw new Error("injected atomic handoff failure"); - } - await fs.rename(source, target); - }, - ...(failurePoint === "canonical-rewrite" - ? { - writeState: async (file: string, value: unknown) => { - if (file.includes(`${path.sep}new-1${path.sep}`)) { - throw new Error("injected canonical rewrite failure"); - } - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); - }, - } - : {}), - }); - resume.mockRejectedValue(new Error("vendor history unavailable")); - const options = ( - service as unknown as { - options: { - readRecord: (id: string) => Promise; - onPlannerSession: PlanningSessionService["options"]["onPlannerSession"]; - }; - } - ).options; - options.readRecord = async (id) => - id === prior.id ? ({ turnCount: 1 } as SessionRecord) : null; - options.onPlannerSession = (value, context) => - firstSuccessor.register(value, context); - - try { - if (failurePoint === "atomic-move") { - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toThrow("injected atomic handoff failure"); - } else { - const first = await service.open(projectId, { - mode: "resume-or-create", - }); - // Exit before readiness. A same-process reopen must follow this exact - // queue-owning successor, even though its canonical rewrite failed. - await manager.kill(first.session.id); - } - - const finalCoordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - options.onPlannerSession = (value, context) => - finalCoordinator.register(value, context); - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - expect(result).toMatchObject({ - resolution: "rehydrated", - session: { - id: "new-2", - rehydratedFrom: - failurePoint === "canonical-rewrite" ? "new-1" : prior.id, - }, - }); - expect(result.session.planning?.queuedInputIds).toHaveLength(1); - result.session.ready = true; - await finalCoordinator.onSessionStatus(result.session); - - expect(manager.submitInput).toHaveBeenCalledTimes(1); - expect(manager.submitInput).toHaveBeenCalledWith( - result.session.id, - "survive handoff fault", - true, - expect.any(Function), - ); - await expect( - fs.access(path.join(root, prior.id, "input-queue.json")), - ).rejects.toMatchObject({ code: "ENOENT" }); - await expect( - fs.access(path.join(root, "new-1", "input-queue.json")), - ).rejects.toMatchObject({ code: "ENOENT" }); - const durable = JSON.parse( - await fs.readFile( - path.join(root, result.session.id, "input-queue.json"), - "utf8", - ), - ) as { inputs: unknown[] }; - expect(durable.inputs).toEqual([]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }, - ); - - it("rehydrates a delivered greeting-only record without generating a duplicate", async () => { - const prior = session("greeting-only", { - status: "exited", - agentSessionId: "missing-vendor-history", - }); - prior.planning!.greeting = { - status: "delivered", - messageId: "greeting-message", - }; - const { service, resume, manager } = fixture([prior]); - resume.mockRejectedValueOnce(new Error("vendor history unavailable")); - const record: SessionRecord = { - harnessSessionId: prior.id, - mergedSessionIds: [prior.id], - agentSessionId: prior.agentSessionId, - harness: prior.harness, - cwd: null, - startedAt: "2026-09-01T00:00:00.000Z", - endedAt: "2026-09-01T00:01:00.000Z", - turns: [ - { - index: 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: "What system should we plan together?", - model: null, - usage: null, - completedAt: "2026-09-01T00:00:30.000Z", - incomplete: false, - }, - ], - turnCount: 0, - eventCount: 2, - reconstructed: true, - archivedAt: null, - limitations: [], - }; - const root = await fs.mkdtemp(path.join(os.tmpdir(), "planner-wired-")); - const coordinator = new PlannerGreetingCoordinator({ - root, - sessionManager: manager, - deliveryTimeoutMs: 60_000, - }); - const options = ( - service as unknown as { - options: { - readRecord: () => Promise; - onPlannerSession: PlanningSessionService["options"]["onPlannerSession"]; - }; - } - ).options; - options.readRecord = async () => record; - options.onPlannerSession = (value, context) => - coordinator.register(value, context); - - try { - const result = await service.open(projectId, { mode: "resume-or-create" }); - expect(result.resolution).toBe("rehydrated"); - expect(result.session.planning?.greeting).toEqual({ - status: "delivered", - messageId: "greeting-message", - }); - expect(manager.submitInput).not.toHaveBeenCalled(); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts deleted file mode 100644 index 8d6e3f87b..000000000 --- a/packages/harness/src/core/planning-session.ts +++ /dev/null @@ -1,614 +0,0 @@ -import type { - AgentMapWorkspaceState, - PlannerLifecycleEvent, - PlannerGreetingState, - PlannerSessionRequest, - PlannerSessionResponse, - PlannerSessionMetadata, - StudioProjectId, -} from "../shared/agent-map.js"; -import type { - CreateSessionRequest, - HarnessSession, - SessionRecord, -} from "../shared/types.js"; -import type { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; -import type { SessionManager } from "./session-manager.js"; -import type { - StudioProjectCatalog, - StudioProjectIdentity, -} from "./studio-project-catalog.js"; -import type { PlannerRegistrationMode } from "./planner-greeting.js"; -import { canonicalGraphPath } from "./canonical-graph-path.js"; -import { AGENT_MAP_PLANNER_SESSION_START_MESSAGE } from "../profiles/agent-map-planner.js"; - -export interface PlannerFocusedContextDetails { - confirmedRevision?: { - digest?: string | null; - summaries?: readonly string[]; - } | null; - activeProposal?: { - status?: string | null; - summary?: string | null; - } | null; - projectBuildPlan?: { - status?: string | null; - summary?: string | null; - } | null; - warnings?: readonly string[]; -} - -export interface PlanningSessionServiceOptions { - catalog: StudioProjectCatalog; - workspaceStore: AgentMapWorkspaceStore; - sessionManager: SessionManager; - readRecord: (id: string) => Promise; - userId: string | null; - /** Live authenticated identity. When omitted, `userId` remains the static - * principal for tests/embedded callers. */ - currentUserId?: () => string | null; - machineId: string; - defaultHarness: CreateSessionRequest["harness"]; - readFocusedContext?: ( - projectId: StudioProjectId, - workspace: AgentMapWorkspaceState, - ) => Promise; - onPlannerSession?: ( - session: HarnessSession, - context: { emptyProject: boolean; mode: PlannerRegistrationMode }, - ) => Promise | void; - onEvent?: (event: PlannerLifecycleEvent) => Promise | void; -} - -export class PlanningSessionError extends Error { - constructor( - readonly code: - | "project_not_found" - | "project_launch_unavailable" - | "session_not_found" - | "forbidden", - ) { - super(code.replace(/_/g, " ")); - this.name = "PlanningSessionError"; - } -} - -export function localPlanningPrincipal( - userId: string | null, - machineId: string, -): string { - return userId ?? `local:${machineId}`; -} - -function launchRoot(project: StudioProjectIdentity): string { - const binding = project.rootBindings.find((entry) => entry.status === "active"); - if (!binding) throw new PlanningSessionError("project_launch_unavailable"); - return binding.localRootRef; -} - -export function isCurrentProjectRoot( - project: StudioProjectIdentity, - cwd: string, -): boolean { - const candidate = canonicalGraphPath(cwd); - return project.rootBindings.some( - (binding) => - binding.status === "active" && - canonicalGraphPath(binding.localRootRef) === candidate, - ); -} - -export async function isPlannerDispatchAuthorized(input: { - session: HarnessSession; - currentPrincipal: () => string; - resolveProject: ( - projectId: StudioProjectId, - ) => Promise; -}): Promise { - const identity = input.session.planning?.identity; - const expectedPrincipal = input.currentPrincipal(); - if (!identity || identity.userId !== expectedPrincipal) return false; - const project = await input.resolveProject(identity.projectId); - return Boolean( - project && - input.currentPrincipal() === expectedPrincipal && - isCurrentProjectRoot(project, input.session.cwd), - ); -} - -function isTerminalGreeting(value: PlannerGreetingState): boolean { - return value.status === "delivered" || value.status === "skipped"; -} - -function planningFor( - projectId: StudioProjectId, - sessionId: string, - userId: string, - greeting: PlannerGreetingState, -): PlannerSessionMetadata { - return { - identity: { projectId, sessionId, userId, role: "map-planner" }, - greeting, - queuedInputIds: [], - }; -} - -export function buildFocusedPlannerContext(input: { - project: StudioProjectIdentity; - workspace: AgentMapWorkspaceState; - sessionId: string; - userId: string; - onboardOnFirstResponse: boolean; - details?: PlannerFocusedContextDetails; -}): string { - const { project, workspace } = input; - const bounded = (value: string, max = 256): string => value.slice(0, max); - const details = input.details ?? {}; - const emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; - const context = { - identity: { - projectId: project.projectId, - sessionId: input.sessionId, - userId: input.userId, - role: "map-planner" as const, - }, - project: { - displayName: bounded(project.displayName), - empty: emptyProject, - confirmedRevision: workspace.confirmedRevisionId - ? { - id: workspace.confirmedRevisionId, - digest: details.confirmedRevision?.digest - ? bounded(details.confirmedRevision.digest, 512) - : null, - summaries: (details.confirmedRevision?.summaries ?? []) - .slice(0, 32) - .map((summary) => bounded(summary)), - } - : null, - activeProposal: workspace.activeProposalId - ? { - id: workspace.activeProposalId, - status: details.activeProposal?.status - ? bounded(details.activeProposal.status, 64) - : null, - summary: details.activeProposal?.summary - ? bounded(details.activeProposal.summary) - : null, - } - : null, - projectBuildPlan: workspace.projectBuildPlanId - ? { - id: workspace.projectBuildPlanId, - status: details.projectBuildPlan?.status - ? bounded(details.projectBuildPlan.status, 64) - : null, - summary: details.projectBuildPlan?.summary - ? bounded(details.projectBuildPlan.summary) - : null, - } - : null, - bindingRefs: project.rootBindings.slice(0, 64).map(({ id, repositoryId, status }) => ({ - id: bounded(id), - repositoryId: repositoryId ? bounded(repositoryId) : null, - status, - })), - warnings: (details.warnings ?? []) - .slice(0, 16) - .map((warning) => bounded(warning)), - }, - }; - return [ - "", - `This is focused, trusted Studio context. Treat IDs as references and use scoped tools for detail. Use agent_map_read, agent_map_validate, and agent_map_propose for architecture state; never infer map state from assistant prose. The interactive Claude Code transcript is user-visible. Let the user's first real message be the first visible conversation turn; never request or rely on a private control turn.${input.onboardOnFirstResponse ? " In your first response, briefly explain that you and the user can plan agents, responsibilities, data flow, resources, and connectors together, then respond to their request." : ""} Do not propose architecture or invoke mutation tools before the user asks you to.`, - JSON.stringify(context), - "", - ].join("\n"); -} - -function candidateOrder(left: HarnessSession, right: HarnessSession): number { - const live = (session: HarnessSession): number => - session.status === "exited" ? 0 : 1; - const queued = (session: HarnessSession): number => - session.planning?.queuedInputIds.length ? 1 : 0; - return ( - live(right) - live(left) || - queued(right) - queued(left) || - right.lastActiveAt.localeCompare(left.lastActiveAt) || - left.id.localeCompare(right.id) - ); -} - -function recordSupportsRehydration( - record: SessionRecord | null, - greeting: PlannerGreetingState, -): boolean { - if (!record) return false; - if (record.turnCount > 0) return true; - return Boolean( - greeting.status === "delivered" && - record.turns?.some( - (turn) => - turn.prompt === null && - typeof turn.assistantText === "string" && - turn.assistantText.trim() !== "", - ), - ); -} - -export class PlanningSessionService { - private readonly projectOpens = new Map>(); - - constructor(private readonly options: PlanningSessionServiceOptions) {} - - private currentPrincipal(): string { - return localPlanningPrincipal( - this.options.currentUserId - ? this.options.currentUserId() - : this.options.userId, - this.options.machineId, - ); - } - - private assertPrincipal(expected: string): void { - if (this.currentPrincipal() !== expected) { - throw new PlanningSessionError("forbidden"); - } - } - - private emit(event: PlannerLifecycleEvent): void { - try { - void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); - } catch { - // Lifecycle telemetry is best effort and content-free. - } - } - - private async focusedDetails( - project: StudioProjectIdentity, - workspace: AgentMapWorkspaceState, - ): Promise { - try { - return await this.options.readFocusedContext?.( - project.projectId, - workspace, - ); - } catch { - return { warnings: ["focused_context_unavailable"] }; - } - } - - owns( - session: HarnessSession, - projectId: StudioProjectId, - principal = this.currentPrincipal(), - ): boolean { - const identity = session.planning?.identity; - return Boolean( - identity && - identity.role === "map-planner" && - identity.sessionId === session.id && - identity.projectId === projectId && - identity.userId === principal, - ); - } - - private async project(projectId: StudioProjectId): Promise { - const project = await this.options.catalog.resolveIdentity(projectId); - if (!project) throw new PlanningSessionError("project_not_found"); - return project; - } - - private async assertRunnable( - projectId: StudioProjectId, - cwd: string, - principal: string, - ): Promise { - this.assertPrincipal(principal); - const current = await this.project(projectId); - this.assertPrincipal(principal); - if (!isCurrentProjectRoot(current, cwd)) { - throw new PlanningSessionError("forbidden"); - } - } - - private async create( - project: StudioProjectIdentity, - request: PlannerSessionRequest, - greeting: PlannerGreetingState, - rehydrateFrom?: string, - mode: "created" | "rehydrated" = "created", - principal = this.currentPrincipal(), - handoffFromSessionId = rehydrateFrom, - ): Promise { - const workspace = await this.options.workspaceStore.readOrCreate( - project.projectId, - ); - const emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; - const harness = request.harness ?? this.options.defaultHarness; - const cwd = launchRoot(project); - const details = await this.focusedDetails(project, workspace); - const session = await this.options.sessionManager.create( - { - cwd, - harness, - ...(request.theme ? { theme: request.theme } : {}), - ...(rehydrateFrom ? { rehydrateFrom } : {}), - }, - { - planning: (sessionId) => - planningFor(project.projectId, sessionId, principal, greeting), - promptAppendix: (sessionId) => - buildFocusedPlannerContext({ - project, - workspace, - sessionId, - userId: principal, - // Claude gets native SessionStart orientation before turn one. - // Other CLIs retain the hidden first-response instruction until - // they expose an equivalent display-only startup channel. - onboardOnFirstResponse: - mode === "created" && emptyProject && harness !== "claude-code", - ...(details ? { details } : {}), - }), - ...(mode === "created" && harness === "claude-code" - ? { - sessionStartSystemMessage: () => - AGENT_MAP_PLANNER_SESSION_START_MESSAGE, - } - : {}), - ...(handoffFromSessionId ? { handoffFromSessionId } : {}), - }, - ); - if (this.currentPrincipal() !== principal) { - // Authentication changed while the process was being spawned. Do not - // return a planner minted for the old principal into the new caller's - // request; terminate the just-created PTY before failing closed. - await this.options.sessionManager.kill(session.id).catch(() => false); - throw new PlanningSessionError("forbidden"); - } - this.emit({ - name: mode === "created" ? "planner_session.created" : "planner_session.resumed", - projectId: project.projectId, - sessionId: session.id, - resolution: mode, - }); - try { - await this.options.onPlannerSession?.(session, { - emptyProject, - mode, - }); - await this.assertRunnable(project.projectId, session.cwd, principal); - } catch (error) { - await this.options.sessionManager.kill(session.id).catch(() => false); - throw error; - } - return session; - } - - private serializeOpen( - projectId: StudioProjectId, - operation: () => Promise, - ): Promise { - const prior = this.projectOpens.get(projectId) ?? Promise.resolve(); - const next = prior.catch(() => {}).then(operation); - this.projectOpens.set(projectId, next); - const cleanup = (): void => { - if (this.projectOpens.get(projectId) === next) { - this.projectOpens.delete(projectId); - } - }; - void next.then(cleanup, cleanup); - return next; - } - - private async rehydrationHistorySource( - candidate: HarnessSession, - projectId: StudioProjectId, - principal: string, - ): Promise { - const visited = new Set(); - let current: HarnessSession | undefined = candidate; - while (current && !visited.has(current.id) && visited.size < 32) { - visited.add(current.id); - const record = await this.options.readRecord(current.id).catch(() => null); - if (recordSupportsRehydration(record, current.planning!.greeting)) { - return current.id; - } - const predecessorId = current.rehydratedFrom; - if (!predecessorId) return undefined; - const predecessor = this.options.sessionManager.get(predecessorId); - if (!predecessor || !this.owns(predecessor, projectId, principal)) { - return undefined; - } - current = predecessor; - } - return undefined; - } - - open( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - return this.serializeOpen(projectId, () => - this.openOnce(projectId, request), - ); - } - - private async openOnce( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - const principal = this.currentPrincipal(); - const project = await this.project(projectId); - this.assertPrincipal(principal); - // Validate that a launch target exists even when an existing candidate is - // reused. Candidate eligibility itself spans every active project root. - launchRoot(project); - if (request.mode === "fresh") { - return { - session: await this.create( - project, - request, - // Claude Code has no hidden assistant-first turn. A pending greeting - // is dispatched as ordinary PTY input and therefore appears as a - // synthetic user message in the raw CLI. Keep onboarding in the - // hidden prompt appendix above and let the user's real input lead. - { status: "skipped", reason: "user-proceeded" }, - undefined, - "created", - principal, - ), - resolution: "created", - }; - } - - const candidates = this.options.sessionManager - .list() - .filter((session) => this.owns(session, projectId, principal)) - .sort(candidateOrder); - for (const candidate of candidates) { - const atCurrentLaunchRoot = isCurrentProjectRoot(project, candidate.cwd); - if ( - atCurrentLaunchRoot && - this.options.sessionManager.isLive(candidate.id) - ) { - const workspace = await this.options.workspaceStore.readOrCreate( - project.projectId, - ); - this.assertPrincipal(principal); - this.emit({ - name: "planner_session.resumed", - projectId, - sessionId: candidate.id, - resolution: "live", - }); - await this.options.onPlannerSession?.(candidate, { - emptyProject: - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null, - mode: "live", - }); - await this.assertRunnable(projectId, candidate.cwd, principal); - return { session: candidate, resolution: "live" }; - } - if (atCurrentLaunchRoot && candidate.agentSessionId) { - const workspace = await this.options.workspaceStore.readOrCreate( - project.projectId, - ); - this.assertPrincipal(principal); - const prior = candidate.planning!.greeting; - const planning = { - ...candidate.planning!, - greeting: isTerminalGreeting(prior) - ? prior - : ({ status: "skipped", reason: "user-proceeded" } as const), - }; - const details = await this.focusedDetails(project, workspace); - this.assertPrincipal(principal); - const resumed = await this.options.sessionManager - .resume(candidate.id, { - planning, - promptAppendix: buildFocusedPlannerContext({ - project, - workspace, - sessionId: candidate.id, - userId: principal, - onboardOnFirstResponse: false, - ...(details ? { details } : {}), - }), - }) - .catch(() => null); - if (resumed) { - if (this.currentPrincipal() !== principal) { - await this.options.sessionManager.kill(resumed.id).catch(() => false); - throw new PlanningSessionError("forbidden"); - } - this.emit({ - name: "planner_session.resumed", - projectId, - sessionId: resumed.id, - resolution: "resumed", - }); - try { - await this.options.onPlannerSession?.(resumed, { - emptyProject: - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null, - mode: "resumed", - }); - await this.assertRunnable(projectId, resumed.cwd, principal); - } catch (error) { - await this.options.sessionManager.kill(resumed.id).catch(() => false); - throw error; - } - return { session: resumed, resolution: "resumed" }; - } - // A stale vendor record may still be safely rehydrated below. - } - const prior = candidate.planning!.greeting; - // A durable planner FIFO is itself rehydration-worthy even before the - // vendor emits history. This keeps a replacement that exits before - // readiness as the exact predecessor for the next launch instead of - // skipping back to an older record and orphaning its moved queue. - const hasQueuedInput = candidate.planning!.queuedInputIds.length > 0; - const historySource = await this.rehydrationHistorySource( - candidate, - projectId, - principal, - ); - if (!historySource && !hasQueuedInput) continue; - const greeting = isTerminalGreeting(prior) - ? prior - : ({ status: "skipped", reason: "user-proceeded" } as const); - return { - session: await this.create( - project, - { ...request, harness: candidate.harness }, - greeting, - historySource, - "rehydrated", - principal, - candidate.id, - ), - resolution: "rehydrated", - }; - } - - return { - session: await this.create( - project, - request, - { status: "skipped", reason: "user-proceeded" }, - undefined, - "created", - principal, - ), - resolution: "created", - }; - } - - async requireOwned( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - const session = this.options.sessionManager.get(sessionId); - const principal = this.currentPrincipal(); - if (!session) throw new PlanningSessionError("session_not_found"); - if (!this.owns(session, projectId, principal)) { - throw new PlanningSessionError("forbidden"); - } - // Ownership metadata alone is insufficient after a project root moves or - // the old root is rebound to another project. Resolve on every operation. - await this.assertRunnable(projectId, session.cwd, principal); - return session; - } -} diff --git a/packages/harness/src/core/project-session-identity.test.ts b/packages/harness/src/core/project-session-identity.test.ts new file mode 100644 index 000000000..f0c447fb9 --- /dev/null +++ b/packages/harness/src/core/project-session-identity.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import { resolveProjectSessionIdentity } from "./project-session-identity.js"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; +const base = { + sessionId: "session-1", + projectId, + userId: "user-1", +} as const; + +const planned = { + projectId, + sessionId: "session-1", + userId: "user-1", + role: "agent-builder", + assignment: { kind: "planned", agentId: "agent_1" }, +} as PlanningSessionIdentity; + +describe("resolveProjectSessionIdentity", () => { + it("issues an unplanned builder identity when nothing is persisted", () => { + expect(resolveProjectSessionIdentity(base)).toEqual({ + projectId, + sessionId: "session-1", + userId: "user-1", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }); + }); + + it("does NOT honor a persisted map-planner identity from 0.14.0 (SAP-3143)", () => { + // The exact row 0.14.0 wrote for a planner session, matching on every + // other field. It must come back as an ordinary builder, not resurrected. + const persisted = { + projectId, + sessionId: "session-1", + userId: "user-1", + role: "map-planner", + } as PlanningSessionIdentity; + + const resolved = resolveProjectSessionIdentity({ ...base, persisted }); + + expect(resolved.role).toBe("agent-builder"); + expect(resolved).toMatchObject({ assignment: { kind: "unplanned" } }); + }); + + it("honors a persisted PLANNED builder assignment, which is server-authored", () => { + const resolved = resolveProjectSessionIdentity({ ...base, persisted: planned }); + + expect(resolved).toEqual(planned); + expect(resolved).not.toBe(planned); + }); + + it("re-issues rather than trusting a persisted row from another session, project, or principal", () => { + for (const persisted of [ + { ...planned, sessionId: "other-session" }, + { ...planned, projectId: "project_00000000-0000-4000-8000-000000000002" }, + { ...planned, userId: "user-2" }, + { ...planned, assignment: { kind: "unplanned" } }, + ] as PlanningSessionIdentity[]) { + const resolved = resolveProjectSessionIdentity({ ...base, persisted }); + expect(resolved).toMatchObject({ + projectId, + sessionId: "session-1", + userId: "user-1", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }); + } + }); +}); diff --git a/packages/harness/src/core/project-session-identity.ts b/packages/harness/src/core/project-session-identity.ts new file mode 100644 index 000000000..850adddc4 --- /dev/null +++ b/packages/harness/src/core/project-session-identity.ts @@ -0,0 +1,39 @@ +import type { PlanningSessionIdentity } from "../shared/agent-map.js"; + +/** + * The Agent Map identity a session launches with. + * + * SAP-3143 removed planner sessions, so a project session is always an + * ordinary `agent-builder`. A persisted identity is honored only when it is + * still exactly this session's, in this project, for this principal, AND it is + * a server-authored planned assignment; anything else (notably a persisted + * `map-planner` row written by 0.14.0) is replaced with a fresh unplanned + * builder identity rather than resurrected. + * + * Extracted from the server so the predicate that runs against a user's real + * `sessions.json` on first boot after upgrade is directly testable. + */ +export function resolveProjectSessionIdentity(input: { + sessionId: string; + projectId: string; + userId: string; + persisted?: PlanningSessionIdentity; +}): PlanningSessionIdentity { + const { sessionId, projectId, userId, persisted } = input; + if ( + persisted?.sessionId === sessionId && + persisted.projectId === projectId && + persisted.userId === userId && + persisted.role === "agent-builder" && + persisted.assignment.kind === "planned" + ) { + return structuredClone(persisted); + } + return { + projectId: projectId as PlanningSessionIdentity["projectId"], + sessionId, + userId, + role: "agent-builder", + assignment: { kind: "unplanned" }, + }; +} diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index c2f6162e4..a59344dee 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -1697,6 +1697,90 @@ describe("SessionManager", () => { ).toBe(true); }); + it("loads a 0.14.0 planner session as an ordinary, resumable session (SAP-3143)", async () => { + // Exactly the shape 0.14.0 persisted: trusted planner metadata plus a + // map-planner Agent Map identity. This is the one path in the planner + // removal that runs against a user's real state on first boot. + const persisted = { + id: "planner-from-0-14", + agentSessionId: "vendor-planner-1", + harness: "claude-code" as const, + cwd: "/tmp/project", + title: "project", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + exitCode: null, + boundWorkflowPath: null, + ready: true, + planning: { + identity: { + projectId: "project_00000000-0000-4000-8000-000000000001", + sessionId: "planner-from-0-14", + userId: "user-1", + role: "map-planner", + }, + greeting: { status: "delivered", messageId: "message-1" }, + queuedInputIds: [], + }, + agentMapIdentity: { + projectId: "project_00000000-0000-4000-8000-000000000001", + sessionId: "planner-from-0-14", + userId: "user-1", + role: "map-planner", + }, + }; + await writeFile(sessionsPath, JSON.stringify([persisted], null, 2)); + + // The server's resolver, narrowed by this change: a persisted map-planner + // identity is not honored, and an ordinary builder identity is issued. + const resolveAgentMapIdentity = vi.fn( + async ( + sessionId: string, + _cwd: string, + prior?: { role: string }, + ) => + prior?.role === "agent-builder" + ? undefined + : ({ + projectId: "project_00000000-0000-4000-8000-000000000001", + sessionId, + userId: "user-1", + role: "agent-builder", + assignment: { kind: "unplanned" }, + } as const), + ); + const { manager, adapter } = makeManager({ resolveAgentMapIdentity }); + await manager.init(); + + // Nothing is lost: the session, its folder and its vendor conversation id + // survive. Only the planner metadata is gone. + const loaded = manager.get(persisted.id); + expect(loaded).toBeDefined(); + expect(loaded?.cwd).toBe("/tmp/project"); + expect(loaded?.agentSessionId).toBe("vendor-planner-1"); + expect(loaded).not.toHaveProperty("planning"); + // The rewrite is durable: a second boot never sees the key again. + const rewritten = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as unknown[]; + expect(JSON.stringify(rewritten)).not.toContain("planning"); + expect(JSON.stringify(rewritten)).not.toContain("greeting"); + + // And it resumes through the ordinary route, as an agent-builder. + const resumed = await manager.resume(persisted.id); + expect(adapter.resume).toHaveBeenCalledWith( + "vendor-planner-1", + expect.objectContaining({ cwd: "/tmp/project" }), + ); + expect(resumed.status).toBe("running"); + expect(resumed).not.toHaveProperty("planning"); + expect(resumed.agentMapIdentity).toMatchObject({ + role: "agent-builder", + assignment: { kind: "unplanned" }, + }); + }); + it("migrates legacy duplicate vendor pointers with first persisted owner winning", async () => { const duplicate = "legacy-shared-vendor"; const first = { diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index 6e88c4045..930821060 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -22,7 +22,6 @@ import { type SpawnSpec, } from "../shared/types.js"; import type { - PlannerSessionMetadata, PlanningSessionIdentity, } from "../shared/agent-map.js"; import { expandHome } from "./paths.js"; @@ -393,8 +392,6 @@ export interface SessionManagerOptions { } export interface TrustedSessionCreateOptions { - /** Server-authored only. Never populated from CreateSessionRequest. */ - planning?: (sessionId: string) => PlannerSessionMetadata; /** Future E5 seam for a server-authored planned builder assignment. */ agentMapIdentity?: (sessionId: string) => PlanningSessionIdentity; /** Focused trusted context composed into the existing system prompt. */ @@ -407,8 +404,6 @@ export interface TrustedSessionCreateOptions { } export interface TrustedSessionResumeOptions { - /** Server-authored only. Used to suppress fresh-only lifecycle work. */ - planning?: PlannerSessionMetadata; /** Recomputed focused context for the resumed process. */ promptAppendix?: string; } @@ -592,6 +587,13 @@ export class SessionManager { session.exitCode = session.exitCode ?? null; dirty = true; } + // Sessions persisted before SAP-3143 carried planner metadata. They + // resume as ordinary sessions; the stale key is dropped so no client + // ever sees a planner again. + if ("planning" in session) { + delete (session as { planning?: unknown }).planning; + dirty = true; + } // Drop a persisted binding that points outside this session's own // workspace. A stale carryover from an earlier session in a different // directory would otherwise render a FOREIGN workflow onto the canvas @@ -647,8 +649,7 @@ export class SessionManager { ): Promise { const id = this.generateId(); const adapter = this.getAdapter(req.harness); - const planning = trusted.planning?.(id); - const trustedIdentity = trusted.agentMapIdentity?.(id) ?? planning?.identity; + const trustedIdentity = trusted.agentMapIdentity?.(id); const agentMapIdentity = this.resolveAgentMapIdentity ? await this.resolveAgentMapIdentity(id, req.cwd, trustedIdentity) : trustedIdentity; @@ -688,8 +689,8 @@ export class SessionManager { exitCode: null, boundWorkflowPath: null, // Ordinary callers record only what the builder actually rehydrated. - // A trusted planner replacement records its exact FIFO predecessor even - // when the brief came from an older recorded ancestor in that chain. + // A trusted replacement records its exact FIFO predecessor even when + // the brief came from an older recorded ancestor in that chain. rehydratedFrom: trusted.handoffFromSessionId ?? opts.rehydratedFrom ?? null, // Persisted so resume() regenerates the same ANSI base — otherwise a @@ -697,7 +698,6 @@ export class SessionManager { // could lose contrast against a differently-themed terminal. ...(req.theme ? { theme: req.theme } : {}), ready: false, - ...(planning ? { planning } : {}), ...(agentMapIdentity ? { agentMapIdentity: structuredClone(agentMapIdentity) } : {}), @@ -812,17 +812,9 @@ export class SessionManager { `Sessions that ended before their first prompt are never written to the coding agent's history, so there is nothing to resume — start a new session in this directory instead.`, ); } - if (trusted.planning) { - session.planning = structuredClone(trusted.planning); - } - const trustedIdentity = trusted.planning?.identity; const agentMapIdentity = this.resolveAgentMapIdentity - ? await this.resolveAgentMapIdentity( - id, - session.cwd, - trustedIdentity ?? session.agentMapIdentity, - ) - : trustedIdentity ?? session.agentMapIdentity; + ? await this.resolveAgentMapIdentity(id, session.cwd, session.agentMapIdentity) + : session.agentMapIdentity; if (agentMapIdentity) session.agentMapIdentity = structuredClone(agentMapIdentity); else delete session.agentMapIdentity; @@ -1564,18 +1556,6 @@ export class SessionManager { this.emitStatus(session); } - /** Persist a coordinator-owned metadata projection before exposing it. */ - async setPlanningMetadata( - id: string, - metadata: PlannerSessionMetadata, - ): Promise { - const session = this.sessions.get(id); - if (!session) throw new UnknownSessionError(id); - session.planning = structuredClone(metadata); - await this.persist(); - this.emitStatus(session); - } - /** * Whether `id` should be treated as ready to receive programmatic input * right now. A real/fallback `session.ready` signal normally suffices; an diff --git a/packages/harness/src/profiles/agent-map-planner.ts b/packages/harness/src/profiles/agent-map-planner.ts deleted file mode 100644 index 61ddea817..000000000 --- a/packages/harness/src/profiles/agent-map-planner.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Standalone launch profile for the project-scoped Agent Map planner. - * - * Planner sessions still run in the real Claude Code or Codex CLI, but they - * must not inherit the ordinary Studio authoring profile: that profile tells - * the model to scaffold, run, and deploy code. Focused project data is appended - * separately by PlanningSessionService for each trusted session. - */ -export const AGENT_MAP_PLANNER_SYSTEM_PROMPT = ` -You are the project planning agent running in Agent Studio. - -Work with the user at the architecture level: plan agents, subagents, -responsibilities, data flow, resources, connectors, artifacts, and the -relationships between them. Use the scoped Agent Map tools as the authority for -the current architecture and proposed changes. - -Do not act as a coding or implementation agent. Do not scaffold agents, edit -application source code, run implementation tasks, or deploy software. -`.trim(); - -/** - * User-facing orientation shown by Claude Code's native SessionStart hook. - * This is deliberately static UI copy, not a synthetic model/user turn. - */ -export const AGENT_MAP_PLANNER_SESSION_START_MESSAGE = [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet. Your planner will turn your goals into a proposed map of agents, responsibilities, data flow, resources, and connectors for you to review and refine. Once approved, Studio will create focused execution sessions from the plan. Start by describing the outcome you want.", -].join("\n"); diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index 1f5753d24..5657a763d 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -12,7 +12,6 @@ import type { LaunchOpts, SpawnSpec, } from "../shared/types.js"; -import { AGENT_MAP_PLANNER_SESSION_START_MESSAGE } from "../profiles/agent-map-planner.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { startServer, type HarnessServer } from "./index.js"; @@ -138,7 +137,7 @@ it("uses the actual ephemeral port and revokes private MCP launch authority on e expect(rejected.status).toBe(401); }); -it("gives a signed-out local planner its scoped Agent Map tools", async () => { +it("gives a signed-out ordinary project session its scoped Agent Map tools", async () => { const codingPrompt = "You are the coding agent running in Agent Studio. Follow the scaffold, run, and deploy authoring loop."; const loadSystemPrompt = vi.fn(async () => codingPrompt); @@ -173,7 +172,9 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { loadSystemPrompt, }); - const response = await fetch( + // SAP-3143: the planner route is gone. A project session is the ordinary + // POST /api/sessions with the project root as cwd, and it gets the map tools. + const removed = await fetch( `http://127.0.0.1:${server.port}/api/projects/${projectId}/planner-sessions`, { method: "POST", @@ -184,28 +185,31 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { body: JSON.stringify({ mode: "fresh", harness: "claude-code" }), }, ); + expect(removed.status).toBe(410); + expect(await removed.json()).toMatchObject({ + code: "planner_sessions_removed", + }); + + const response = await fetch(`http://127.0.0.1:${server.port}/api/sessions`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-harness-token": "boot-token", + }, + body: JSON.stringify({ cwd: projectRoot, harness: "claude-code" }), + }); expect(response.status).toBe(201); const created = (await response.json()) as { - session: { - id: string; - planning: { - identity: { role: string; userId: string }; - greeting: { status: string; reason?: string }; - }; - agentMapIdentity?: { role: string; userId: string }; - }; + id: string; + agentMapIdentity?: { role: string; userId: string; assignment?: unknown }; + planning?: unknown; }; - expect(created.session.planning.identity).toMatchObject({ - role: "map-planner", + expect(created.agentMapIdentity).toMatchObject({ + role: "agent-builder", userId: "local:machine-1", + assignment: { kind: "unplanned" }, }); - expect(created.session.agentMapIdentity).toEqual( - created.session.planning.identity, - ); - expect(created.session.planning.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); + expect(created.planning).toBeUndefined(); const launchOpts = launches[0]!; const metadata = launchOpts.agentMapMcp; @@ -217,34 +221,18 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { expect(config.mcpServers["agent-map"].headers.Authorization).toBe( `Bearer ${metadata!.bearerToken}`, ); + // The ordinary served prompt, untouched: no planner profile, no planner + // context, no prohibition, no SessionStart planner orientation. const systemPrompt = await fs.readFile(launchOpts!.systemPromptFile!, "utf8"); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain( - "Do not act as a coding or implementation agent", - ); - expect(systemPrompt).toContain( - "Let the user's first real message be the first visible conversation turn", - ); - expect(systemPrompt).not.toContain("In your first response, briefly explain"); - expect(systemPrompt).not.toContain(codingPrompt); - expect(systemPrompt).not.toContain("You are the coding agent"); - expect(systemPrompt).not.toContain( - "This is a private Agent Studio control turn", - ); - expect(loadSystemPrompt).not.toHaveBeenCalled(); - expect(AGENT_MAP_PLANNER_SESSION_START_MESSAGE).toBe( - [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet. Your planner will turn your goals into a proposed map of agents, responsibilities, data flow, resources, and connectors for you to review and refine. Once approved, Studio will create focused execution sessions from the plan. Start by describing the outcome you want.", - ].join("\n"), - ); - const plannerEmitter = await fs.readFile( + expect(systemPrompt).toBe(codingPrompt); + expect(systemPrompt).not.toContain("planner"); + expect(systemPrompt).not.toContain("Do not act as a coding"); + const emitter = await fs.readFile( path.join(path.dirname(launchOpts.settingsFile!), "emit.cjs"), "utf8", ); - expect(plannerEmitter).toContain( - `const sessionStartSystemMessage = ${JSON.stringify(AGENT_MAP_PLANNER_SESSION_START_MESSAGE)};`, - ); + expect(emitter).toContain("const sessionStartSystemMessage = null;"); + expect(loadSystemPrompt).toHaveBeenCalledOnce(); const client = new Client({ name: "signed-out-planner-test", version: "1" }); const transport = new StreamableHTTPClientTransport(new URL(metadata!.url), { @@ -334,10 +322,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { "utf8", ); expect(ordinaryEmitter).toContain("const sessionStartSystemMessage = null;"); - expect(ordinaryEmitter).not.toContain( - JSON.stringify(AGENT_MAP_PLANNER_SESSION_START_MESSAGE), - ); - expect(loadSystemPrompt).toHaveBeenCalledOnce(); + expect(loadSystemPrompt).toHaveBeenCalledTimes(2); const ordinaryConfig = JSON.parse( await fs.readFile(ordinaryLaunch.mcpConfigFile!, "utf8"), ); diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts index 2e1a75771..657898526 100644 --- a/packages/harness/src/server/agent-map.test.ts +++ b/packages/harness/src/server/agent-map.test.ts @@ -8,16 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; -import { - PlannerGreetingRetryUnavailableError, - type PlannerGreetingCoordinator, -} from "../core/planner-greeting.js"; -import { - PlanningSessionError, - type PlanningSessionService, -} from "../core/planning-session.js"; import type { AgentMapWorkspaceResponse } from "../shared/agent-map.js"; -import type { HarnessSession } from "../shared/types.js"; import { createBootTokenMiddleware } from "./auth.js"; import { createAgentMapRouter } from "./agent-map.js"; @@ -36,10 +27,7 @@ describe("createAgentMapRouter", () => { ); }); - async function start(planner?: { - planningSessions: PlanningSessionService; - plannerGreeting: PlannerGreetingCoordinator; - }) { + async function start() { const stateRoot = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-router-"), ); @@ -83,7 +71,6 @@ describe("createAgentMapRouter", () => { ], isWorkflowScanComplete: () => true, listWorkspaceScopes, - ...planner, }), ); server = app.listen(0); @@ -442,70 +429,15 @@ describe("createAgentMapRouter", () => { expect(await fs.readFile(workspacePath, "utf8")).toBe("{bad-json"); }); - it("protects planner routes and accepts only project-scoped intent", async () => { - let fixtureProjectId = ""; - const plannerSession = { - id: "planner-session-1", - agentSessionId: null, - harness: "codex", - cwd: "/server/private/project", - title: "project", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: false, - } as HarnessSession; - const open = vi.fn(async () => ({ - session: plannerSession, - resolution: "created" as const, - })); - const requireOwned = vi.fn(() => plannerSession); - const enqueue = vi.fn(async () => ({ - identity: { - projectId: fixtureProjectId, - sessionId: plannerSession.id, - userId: "user-1", - role: "map-planner" as const, - }, - greeting: { status: "pending" as const }, - queuedInputIds: ["input-1"], - })); - const retry = vi.fn(async () => { - if (!plannerSession.planning) throw new Error("missing planner metadata"); - plannerSession.planning = { - ...plannerSession.planning, - greeting: { status: "generating", attemptId: "attempt-2" }, - }; - }); - const fixture = await start({ - planningSessions: { - open, - requireOwned, - } as unknown as PlanningSessionService, - plannerGreeting: { - enqueue, - retry, - } as unknown as PlannerGreetingCoordinator, - }); - fixtureProjectId = fixture.project.projectId; - plannerSession.planning = { - identity: { - projectId: fixtureProjectId, - sessionId: plannerSession.id, - userId: "user-1", - role: "map-planner", - }, - greeting: { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - }, - queuedInputIds: [], - }; + it("answers the removed planner routes with 410 and a reason (SAP-3143)", async () => { + const fixture = await start(); const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions`; + const headers = { + "content-type": "application/json", + "X-Harness-Token": "test-token", + }; + // The boot-token gate still comes first. expect( ( await fetch(route, { @@ -515,133 +447,18 @@ describe("createAgentMapRouter", () => { }) ).status, ).toBe(401); - const forged = await fetch(route, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ - mode: "fresh", - role: "map-planner", - projectId: fixture.project.projectId, - }), - }); - expect(forged.status).toBe(400); - expect(open).not.toHaveBeenCalled(); - - const valid = await fetch(route, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ mode: "fresh", harness: "codex" }), - }); - expect(valid.status).toBe(201); - expect(open).toHaveBeenCalledWith(fixture.project.projectId, { - mode: "fresh", - harness: "codex", - }); - - const message = await fetch(`${route}/${plannerSession.id}/messages`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ text: "Build a support triage system" }), - }); - expect(message.status).toBe(202); - expect(await message.json()).toEqual({ - metadata: { - identity: { - projectId: fixture.project.projectId, - sessionId: plannerSession.id, - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, - queuedInputIds: ["input-1"], - }, - }); - expect(requireOwned).toHaveBeenCalledWith( - fixture.project.projectId, - plannerSession.id, - ); - expect(enqueue).toHaveBeenCalledWith( - plannerSession.id, - "Build a support triage system", - ); - - const retryResponse = await fetch( - `${route}/${plannerSession.id}/greeting/retry`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: "{}", - }, - ); - expect(retryResponse.status).toBe(202); - expect(await retryResponse.json()).toEqual({ - metadata: { - ...plannerSession.planning, - greeting: { status: "generating", attemptId: "attempt-2" }, - }, - }); - expect(retry).toHaveBeenCalledWith(plannerSession.id); - }); - it("rejects foreign planner messages and bounds unavailable retries", async () => { - const requireOwned = vi.fn<() => Promise>(async () => { - throw new PlanningSessionError("forbidden"); - }); - const enqueue = vi.fn(async () => ({}) as never); - const retry = vi.fn(async () => { - throw new PlannerGreetingRetryUnavailableError(); - }); - const fixture = await start({ - planningSessions: { - open: vi.fn(), - requireOwned, - } as unknown as PlanningSessionService, - plannerGreeting: { - enqueue, - retry, - } as unknown as PlannerGreetingCoordinator, - }); - const headers = { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }; - const message = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/foreign/messages`, - { method: "POST", headers, body: JSON.stringify({ text: "hello" }) }, - ); - expect(message.status).toBe(403); - expect(await message.json()).toMatchObject({ code: "forbidden" }); - expect(enqueue).not.toHaveBeenCalled(); - - const forbiddenRetry = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/foreign/greeting/retry`, - { method: "POST", headers, body: "{}" }, - ); - expect(forbiddenRetry.status).toBe(403); - expect(await forbiddenRetry.json()).toMatchObject({ code: "forbidden" }); - expect(retry).not.toHaveBeenCalled(); - - requireOwned.mockResolvedValue({ id: "owned" } as HarnessSession); - const retryResponse = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/owned/greeting/retry`, - { method: "POST", headers, body: "{}" }, - ); - expect(retryResponse.status).toBe(409); - expect(await retryResponse.json()).toEqual({ - code: "greeting_retry_unavailable", - error: "greeting retry is not available", - }); + for (const [method, path, body] of [ + ["POST", route, JSON.stringify({ mode: "fresh" })], + ["POST", `${route}/session-1/messages`, JSON.stringify({ text: "hi" })], + ["POST", `${route}/session-1/greeting/retry`, "{}"], + ] as const) { + const response = await fetch(path, { method, headers, body }); + expect(response.status).toBe(410); + expect(await response.json()).toMatchObject({ + code: "planner_sessions_removed", + error: expect.stringContaining("ordinary session"), + }); + } }); }); diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index cf83faa24..ead898c0d 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -5,11 +5,9 @@ import { type AgentMapErrorCode, type AgentMapErrorResponse, type AgentMapWorkspaceResponse, - type PlannerMessageRequest, - type PlannerSessionRequest, type StudioWorkspaceSelection, } from "../shared/agent-map.js"; -import { SPAWNABLE_HARNESS_KINDS, type WorkflowInfo } from "../shared/types.js"; +import type { WorkflowInfo } from "../shared/types.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import { AgentMapWorkspaceStore, @@ -24,15 +22,6 @@ import { StudioWorkspacePreferenceStore, StudioWorkspacePreferenceStoreError, } from "../core/studio-workspace-preferences.js"; -import { - PlanningSessionError, - type PlanningSessionService, -} from "../core/planning-session.js"; -import { - PlannerDispatchForbiddenError, - PlannerGreetingRetryUnavailableError, - type PlannerGreetingCoordinator, -} from "../core/planner-greeting.js"; export interface AgentMapRouterOptions { catalog: StudioProjectCatalog; @@ -50,39 +39,6 @@ export interface AgentMapRouterOptions { listWorkspaceScopes: () => | readonly WorkspaceScopeSummary[] | Promise; - planningSessions?: PlanningSessionService; - plannerGreeting?: PlannerGreetingCoordinator; -} - -const plannerSessionSchema = z - .object({ - mode: z.enum(["resume-or-create", "fresh"]), - harness: z.enum(SPAWNABLE_HARNESS_KINDS).optional(), - theme: z.enum(["light", "dark"]).optional(), - }) - .strict() satisfies z.ZodType; - -const plannerMessageSchema = z - .object({ text: z.string().min(1).max(100_000) }) - .strict() satisfies z.ZodType; - -function sendPlanningError( - res: import("express").Response, - error: unknown, -): boolean { - if (error instanceof PlannerDispatchForbiddenError) { - res.status(403).json({ code: error.code, error: error.message }); - return true; - } - if (!(error instanceof PlanningSessionError)) return false; - const status = - error.code === "project_not_found" || error.code === "session_not_found" - ? 404 - : error.code === "forbidden" - ? 403 - : 409; - res.status(status).json({ code: error.code, error: error.message }); - return true; } const ERROR_MESSAGES: Record = { @@ -358,86 +314,16 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { } }); - router.post( - "/projects/:projectId/planner-sessions", - async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); - return; - } - const parsed = plannerSessionSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "Invalid planner session request" }); - return; - } - try { - await options.catalog.reconcile(await options.listWorkspaceScopes()); - const result = await options.planningSessions.open( - req.params.projectId, - parsed.data, - ); - res.status(result.resolution === "created" ? 201 : 200).json(result); - } catch (error) { - if (!sendPlanningError(res, error)) next(error); - } - }, - ); - - router.post( - "/projects/:projectId/planner-sessions/:sessionId/messages", - async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); - return; - } - const parsed = plannerMessageSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "Invalid planner message" }); - return; - } - try { - await options.planningSessions.requireOwned( - req.params.projectId, - req.params.sessionId, - ); - const metadata = await options.plannerGreeting.enqueue( - req.params.sessionId, - parsed.data.text, - ); - res.status(202).json({ metadata }); - } catch (error) { - if (!sendPlanningError(res, error)) next(error); - } - }, - ); - - /** @deprecated Compatibility-only for sessions created before synthetic greeting removal. */ - router.post( - "/projects/:projectId/planner-sessions/:sessionId/greeting/retry", - async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); - return; - } - if (Object.keys((req.body ?? {}) as object).length > 0) { - res.status(400).json({ error: "Invalid greeting retry request" }); - return; - } - try { - const session = await options.planningSessions.requireOwned( - req.params.projectId, - req.params.sessionId, - ); - await options.plannerGreeting.retry(req.params.sessionId); - res.status(202).json({ - metadata: session.planning, - }); - } catch (error) { - if (error instanceof PlannerGreetingRetryUnavailableError) { - res.status(409).json({ code: error.code, error: error.message }); - } else if (!sendPlanningError(res, error)) next(error); - } - }, - ); + // SAP-3143: Studio no longer creates planner sessions. A project session is + // an ordinary session (POST /api/sessions with the project root as cwd) that + // also has the Agent Map tools. A stale client that still calls the planner + // routes gets a reason, not a 404 it would mistake for a missing project. + router.all("/projects/:projectId/planner-sessions*", (_req, res) => { + res.status(410).json({ + code: "planner_sessions_removed", + error: + "Planner sessions were removed. Start an ordinary session in the project root; it has the Agent Map tools.", + }); + }); return router; } diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 00382cab1..d72d5e843 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -31,7 +31,6 @@ import type { WorkflowInfo, } from "../shared/types.js"; import { JSON_BODY_LIMIT_BYTES } from "../shared/types.js"; -import type { PlannerLifecycleEvent } from "../shared/agent-map.js"; import { unhandledRequestErrorHandler } from "./error-handler.js"; import { expandHome, resolveStatePaths } from "../core/paths.js"; import { @@ -98,7 +97,6 @@ import { sweepGeneratedDirs, } from "../core/inject/retention.js"; import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; -import { AGENT_MAP_PLANNER_SYSTEM_PROMPT } from "../profiles/agent-map-planner.js"; import { fetchSystemPromptForActiveEnvironment } from "../profiles/system-prompt-fetch.js"; import { agentCoreTemplatesDir } from "../core/agent-core-templates.js"; import { CanvasWatcherManager } from "../core/canvas-watcher.js"; @@ -163,18 +161,13 @@ import { type AgentMapCapabilityEvent, } from "../core/agent-map-capability-registry.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { resolveProjectSessionIdentity } from "../core/project-session-identity.js"; import { createAgentMapMcpRouter, type AgentMapMcpRouter, } from "./agent-map-mcp.js"; import { AgentMapMcpProjectUnavailableError } from "./agent-map-mcp-tools.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; -import { - isPlannerDispatchAuthorized, - localPlanningPrincipal, - PlanningSessionService, -} from "../core/planning-session.js"; -import { PlannerGreetingCoordinator } from "../core/planner-greeting.js"; import { IngestCredentialRegistry } from "../core/ingest-credentials.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; @@ -571,13 +564,10 @@ function createDefaultBuildLaunchOpts( // resolves to the bundled DEFAULT_SYSTEM_PROMPT on any failure rather than // throwing; the `.catch` covers an injected loader that does not, because a // session must never fail to start over the text of its prompt. - const promptPromise = - context?.agentMapIdentity?.role === "map-planner" - ? Promise.resolve(AGENT_MAP_PLANNER_SYSTEM_PROMPT) - : loadSystemPrompt().catch((err: unknown) => { - console.error("[harness] system-prompt load failed:", err); - return DEFAULT_SYSTEM_PROMPT; - }); + const promptPromise = loadSystemPrompt().catch((err: unknown) => { + console.error("[harness] system-prompt load failed:", err); + return DEFAULT_SYSTEM_PROMPT; + }); const [settings, mcpConfigFile, prompt, pluginDir] = await Promise.all([ generateClaudeSettings({ harnessSessionId, @@ -626,6 +616,13 @@ function createDefaultBuildLaunchOpts( }; } +/** The Agent Map principal: the signed-in user, or a stable machine-local id + * when Studio runs signed out, so capability issuance never depends on auth. */ +const localPlanningPrincipal = ( + userId: string | null, + machineId: string, +): string => userId ?? `local:${machineId}`; + export const startServer = async ( options: HarnessServerOptions, ): Promise => { @@ -1169,30 +1166,22 @@ export const startServer = async ( sessionsPath: options.sessionsPath ?? statePaths.sessions, buildLaunchOpts, resolveAgentMapIdentity: async (sessionId, cwd, persisted) => { - // Planner ownership already uses a stable machine-local principal when - // Studio runs with --no-auth. Capability issuance must use that same - // identity; requiring an authenticated user here silently removed the - // Agent Map server from every signed-out planner's MCP config. + // Signed-out Studio uses a stable machine-local principal. Capability + // issuance must use that same identity; requiring an authenticated user + // here silently removed the Agent Map server from every signed-out + // session's MCP config. const userId = localPlanningPrincipal(planningUserId, machineId); const project = await studioProjectCatalog.resolveIdentityForPath(cwd); if (!project) return undefined; - if ( - persisted?.sessionId === sessionId && - persisted.projectId === project.projectId && - persisted.userId === userId && - (persisted.role === "map-planner" || - (persisted.role === "agent-builder" && - persisted.assignment.kind === "planned")) - ) { - return structuredClone(persisted); - } - return { - projectId: project.projectId, + // A persisted `map-planner` identity (from before SAP-3143) is not + // honored: every project session is an ordinary agent-builder now. The + // predicate lives in core so it is testable on its own. + return resolveProjectSessionIdentity({ sessionId, + projectId: project.projectId, userId, - role: "agent-builder", - assignment: { kind: "unplanned" }, - }; + ...(persisted ? { persisted } : {}), + }); }, onAgentMapSessionExit: async (sessionId) => { agentMapCapabilities.revokeSession(sessionId); @@ -2621,52 +2610,6 @@ export const startServer = async ( // and createIngestRouter) so the uiTrack closure can reference it lazily. const seqCounter = createSeqCounter(); - const emitPlannerLifecycle = (event: PlannerLifecycleEvent): void => { - const session = sessionManager.get(event.sessionId); - const analyticsEvent: AnalyticsEvent = { - eventId: randomUUID(), - seq: seqCounter.next(event.sessionId), - ts: new Date().toISOString(), - userId: identity?.userId ?? null, - tenantId: identity?.tenantId ?? null, - machineId, - harnessSessionId: event.sessionId, - // Planner lifecycle correlation uses the server-owned harness session. - // Provider session identity is unnecessary and may originate in a hook. - agentSessionId: null, - harness: session?.harness ?? "claude-code", - type: event.name, - payload: { - project_id: event.projectId, - ...("resolution" in event - ? { resolution: event.resolution } - : { - queue_depth: Math.max(0, Math.min(10_000, event.queueDepth)), - ...("attemptId" in event && event.attemptId - ? { attempt_id: event.attemptId } - : {}), - ...(event.name === "planner_greeting.failed" - ? { - error_code: event.errorCode, - retryable: event.retryable, - } - : {}), - ...(event.name === "planner_greeting.skipped" - ? { reason: event.reason } - : {}), - ...(event.name === "planner_session.input_delivery_uncertain" - ? { - input_id: event.inputId, - error_code: event.errorCode, - } - : {}), - }), - }, - }; - void eventStore.append(analyticsEvent).catch(() => {}); - batcher.enqueue(analyticsEvent); - }; - const agentMapWorkspaceStore = new AgentMapWorkspaceStore( statePaths.agentMap, { @@ -2817,85 +2760,6 @@ export const startServer = async ( }); }; - const plannerGreeting = new PlannerGreetingCoordinator({ - root: statePaths.plannerSessions, - sessionManager, - canDispatch: (session) => - isPlannerDispatchAuthorized({ - session, - currentPrincipal: () => - localPlanningPrincipal(planningUserId, machineId), - resolveProject: (projectId) => - studioProjectCatalog.resolveIdentity(projectId), - }), - onEvent: emitPlannerLifecycle, - }); - for (const session of sessionManager.list()) { - if (!session.planning) continue; - let emptyProject = true; - try { - const workspace = await agentMapWorkspaceStore.readOrCreate( - session.planning.identity.projectId, - ); - emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; - } catch { - // Registration still recovers generating state and preserves its FIFO; - // the project route will surface any unavailable workspace later. - } - await plannerGreeting - .register(session, { emptyProject, mode: "boot" }) - .catch((error: unknown) => { - console.error( - `[harness] planner registration failed for ${session.id}:`, - error instanceof Error ? error.message : "unknown error", - ); - }); - } - const planningSessions = new PlanningSessionService({ - catalog: studioProjectCatalog, - workspaceStore: agentMapWorkspaceStore, - sessionManager, - readRecord: (id) => sessionRecordReader.read(id), - userId: identity?.userId ?? null, - currentUserId: () => planningUserId, - machineId, - defaultHarness: options.defaultHarnessKind ?? "claude-code", - // E1 owns the durable workspace pointers, but not the later revision, - // proposal, or build-plan detail records. Wire that shipped source - // explicitly so the focused-context contract emits honest null/empty - // detail slots today and has one allowlisted adapter boundary when those - // stores land; it must never fall back to scanning project files. - readFocusedContext: async (_projectId, workspace) => ({ - confirmedRevision: - workspace.confirmedRevisionId === null - ? null - : { digest: null, summaries: [] }, - activeProposal: - workspace.activeProposalId === null - ? null - : { status: null, summary: null }, - projectBuildPlan: - workspace.projectBuildPlanId === null - ? null - : { status: null, summary: null }, - warnings: [], - }), - onPlannerSession: (session, context) => - plannerGreeting.register(session, context), - onEvent: emitPlannerLifecycle, - }); - sessionManager.onStatusChange((session) => { - void plannerGreeting.onSessionStatus(session).catch((error: unknown) => { - console.error( - "[harness] planner greeting status transition failed:", - error, - ); - }); - }); - const app: Express = express(); app.disable("x-powered-by"); @@ -2991,8 +2855,6 @@ export const startServer = async ( listWorkflows: () => workflowsCache, isWorkflowScanComplete, listWorkspaceScopes: () => workspaceScopeCatalog.list(), - planningSessions, - plannerGreeting, }), ); app.use( @@ -3411,8 +3273,6 @@ export const startServer = async ( store: eventStore, batcher, enrichFromTranscript: enrichTurnCompleted, - decorateEvent: (event) => plannerGreeting.decorateLocalEvent(event), - projectTelemetryEvent: (event) => plannerGreeting.redactForTelemetry(event), onNormalizedEvent: (event: AnalyticsEvent) => { // Synchronous and total — it counts turns and detaches any fold it // decides to start, so the ingest path never waits on a summary. @@ -3440,9 +3300,6 @@ export const startServer = async ( } }, onEventPersisted: (event: AnalyticsEvent) => { - void plannerGreeting.onEventPersisted(event).catch((error: unknown) => { - console.error("[harness] planner greeting completion failed:", error); - }); const recordChanged = sessionRecordChangedMessage(event); if (recordChanged) bus.publish(recordChanged); // The normal end of a session: the SessionEnd hook's event is in the diff --git a/packages/harness/src/server/ingest.test.ts b/packages/harness/src/server/ingest.test.ts index 6d6e765c1..587ba1c71 100644 --- a/packages/harness/src/server/ingest.test.ts +++ b/packages/harness/src/server/ingest.test.ts @@ -9,10 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeHookEvent } from "../core/collector/normalizer.js"; import { createSeqCounter } from "../core/collector/seq.js"; import { createEventStore } from "../core/collector/store.js"; -import { PlannerGreetingCoordinator } from "../core/planner-greeting.js"; import { createSessionRecordReader } from "../core/session-record.js"; -import type { SessionManager } from "../core/session-manager.js"; -import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; +import type { AnalyticsEvent } from "../shared/types.js"; import { createIngestRouter, processIngest, @@ -646,91 +644,6 @@ describe("createIngestRouter", () => { expect(JSON.stringify(enqueued[0])).not.toContain("private control prompt"); }); - it("bounds hostile planner source, model, and usage before batching", async () => { - const planningSession = { - agentSessionId: "agent-1", - planning: { - identity: { - projectId: "project-1", - sessionId: "session-1", - userId: "user-1", - role: "map-planner", - }, - }, - } as unknown as HarnessSession; - const privacy = new PlannerGreetingCoordinator({ - root: "/unused", - sessionManager: { - get: () => planningSession, - } as unknown as SessionManager, - }); - start({ - enrichFromTranscript: async (event) => ({ - ...event, - payload: { - ...event.payload, - model: "secret/customer_key_123", - usage: { - inputTokens: 10 ** 30, - outputTokens: -7, - secret: "/private/customer-token", - }, - }, - }), - projectTelemetryEvent: (event) => privacy.redactForTelemetry(event), - }); - - await postIngest(baseUrl, { - hookEvent: "SessionStart", - harnessSessionId: "session-1", - payload: { - session_id: "agent-1", - source: "/private/customer-token", - }, - }); - await postIngest(baseUrl, { - hookEvent: "UserPromptSubmit", - harnessSessionId: "session-1", - payload: { - session_id: "/private/envelope-secret", - prompt: "private prompt text", - }, - }); - await postIngest(baseUrl, { - hookEvent: "Stop", - harnessSessionId: "session-1", - payload: { - session_id: "/private/envelope-secret", - transcript_path: "/private/transcript.jsonl", - last_assistant_message: "private assistant text", - }, - }); - - await vi.waitFor(() => expect(enqueued).toHaveLength(3)); - expect(enqueued.map((event) => event.payload)).toEqual([ - { planner: true, source: "unknown" }, - { planner: true, origin: "user" }, - { - planner: true, - hasAssistantText: true, - modelReported: true, - usage: { inputTokens: 1_000_000_000_000, outputTokens: null }, - }, - ]); - expect(enqueued.map((event) => event.agentSessionId)).toEqual([ - null, - null, - null, - ]); - expect(stored.map((event) => event.agentSessionId)).toEqual([ - "agent-1", - "agent-1", - "agent-1", - ]); - expect(JSON.stringify(enqueued)).not.toContain("private"); - expect(JSON.stringify(enqueued)).not.toContain("secret"); - }); - it("does not call onNormalizedEvent for a hook with no analytics mapping", async () => { const onNormalizedEvent = vi.fn(); start({ onNormalizedEvent }); diff --git a/packages/harness/src/server/rest.test.ts b/packages/harness/src/server/rest.test.ts index 250f9bd76..c1c55d194 100644 --- a/packages/harness/src/server/rest.test.ts +++ b/packages/harness/src/server/rest.test.ts @@ -767,35 +767,6 @@ describe("createRestRouter", () => { expect(res.status).toBe(400); }); - it("requires planner input to use the project-scoped FIFO", async () => { - const planner = exitedSession({ - id: "planner-1", - planning: { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, - queuedInputIds: [], - }, - }); - const sessionManager = fakeSessionManager([planner]); - start({ sessionManager }); - - const res = await fetch(`${baseUrl}/sessions/planner-1/input`, { - method: "POST", - headers: { ...TOKEN_HEADER, "content-type": "application/json" }, - body: JSON.stringify({ text: "bypass" }), - }); - expect(res.status).toBe(409); - expect(await res.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", - }); - expect(sessionManager.submitInput).not.toHaveBeenCalled(); - }); - it("404s when submitInput reports no live pty for the session", async () => { const sessionManager = fakeSessionManager(); ( @@ -965,34 +936,6 @@ describe("createRestRouter", () => { }); describe("POST /sessions/:id/resume — error class → HTTP status mapping", () => { - it("requires planner resume to use the trusted project resolver", async () => { - const planner = exitedSession({ - id: "planner-1", - planning: { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, - }); - const sessionManager = fakeSessionManager([planner]); - start({ sessionManager }); - - const res = await fetch(`${baseUrl}/sessions/planner-1/resume`, { - method: "POST", - headers: TOKEN_HEADER, - }); - expect(res.status).toBe(409); - expect(await res.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", - }); - expect(sessionManager.resume).not.toHaveBeenCalled(); - }); - it("404s when resume() throws UnknownSessionError (class-based dispatch, not string match)", async () => { const sessionManager = fakeSessionManager(); (sessionManager.resume as ReturnType).mockRejectedValue( @@ -1400,81 +1343,6 @@ describe("createRestRouter", () => { expect(sessionManager.resume).toHaveBeenCalledWith("sess-existing"); }); - it("requires an existing foreign-owned planner to use its scoped route without mutation", async () => { - const planner = exitedSession({ - id: "planner-existing", - agentSessionId: body.agentSessionId, - planning: { - identity: { - projectId: "foreign-project", - sessionId: "planner-existing", - userId: "foreign-user", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, - }); - const original = structuredClone(planner.planning); - const sessionManager = fakeSessionManager([planner]); - const canResume = vi.fn(async () => true); - start({ - sessionManager, - adapters: { - "claude-code": historyAdapter({ canResume }), - }, - }); - - const res = await adopt({ ...body, cwd: "/tmp/client-supplied-alias" }); - - expect(res.status).toBe(409); - expect(await res.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", - }); - expect(canResume).not.toHaveBeenCalled(); - expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); - expect(sessionManager.resume).not.toHaveBeenCalled(); - expect(sessionManager.get("planner-existing")?.planning).toEqual(original); - }); - - it("rejects a rotated planner's durable old alias even though its current pointer changed", async () => { - const planner = exitedSession({ - id: "planner-rotated", - agentSessionId: "vendor-new", - planning: { - identity: { - projectId: "project-1", - sessionId: "planner-rotated", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, - }); - const sessionManager = fakeSessionManager([planner]); - ( - sessionManager.getAgentSessionOwner as unknown as ReturnType - ).mockImplementation((agentSessionId: string) => - agentSessionId === body.agentSessionId ? planner : undefined, - ); - const canResume = vi.fn(async () => true); - start({ - sessionManager, - adapters: { "claude-code": historyAdapter({ canResume }) }, - }); - - const response = await adopt(body); - - expect(response.status).toBe(409); - expect(await response.json()).toMatchObject({ - code: "planner_session_requires_scoped_route", - }); - expect(canResume).not.toHaveBeenCalled(); - expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); - expect(sessionManager.resume).not.toHaveBeenCalled(); - }); - it("409s a generic session's historical alias before any adapter probe or mutation", async () => { const owner = exitedSession({ id: "generic-rotated", diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index 4d727d821..b9182934a 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -728,28 +728,14 @@ export function createRestRouter(options: RestRouterOptions): Router { const cwd = normalizeCwd(parsed.data.cwd); try { // Resolve an already-owned registry row before probing or mutating any - // adapter state. Generic adoption must never bypass the project/user - // authority and focused-context checks on the scoped planner route. + // adapter state. const durableOwner = sessionManager.getAgentSessionOwner(agentSessionId); const identityReserved = sessionManager.isAgentSessionIdentityReserved(agentSessionId); const identityOwners = sessionManager .list() .filter((session) => session.agentSessionId === agentSessionId); - if ( - durableOwner?.planning !== undefined || - identityOwners.some((session) => session.planning !== undefined) - ) { - res.status(409).json({ - code: "planner_session_requires_scoped_route", - error: "Planner sessions must be resumed through their project route", - }); - return; - } - // For ordinary sessions, cwd remains part of the historical-record - // identity. It is deliberately checked only after the vendor id has - // been fenced from every planner owner above: client-supplied cwd must - // not alias around the scoped planner route. + // cwd remains part of the historical-record identity. const existing = identityOwners.find( (session) => normalizeCwd(session.cwd) === cwd, ); @@ -824,13 +810,6 @@ export function createRestRouter(options: RestRouterOptions): Router { }); router.post("/sessions/:id/resume", async (req, res, next) => { - if (sessionManager.get(req.params.id)?.planning) { - res.status(409).json({ - code: "planner_session_requires_scoped_route", - error: "Planner sessions must be resumed through their project route", - }); - return; - } try { const session = await sessionManager.resume(req.params.id); res.json(session); @@ -856,13 +835,6 @@ export function createRestRouter(options: RestRouterOptions): Router { res.status(400).json({ error: parsed.error.message }); return; } - if (sessionManager.get(req.params.id)?.planning) { - res.status(409).json({ - code: "planner_session_requires_scoped_route", - error: "Planner input must use the project-scoped message route", - }); - return; - } try { const submit = parsed.data.submit ?? true; const ok = await sessionManager.submitInput( diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index dba536280..4eb766fe9 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -333,105 +333,3 @@ export interface AgentMapReadSnapshot { proposal: MapChangeProposal | null; } -export type PlannerGreetingErrorCode = - | "session_not_ready" - | "session_exited" - | "injection_failed" - | "model_turn_failed" - | "delivery_timeout" - | "persistence_failed"; - -export type PlannerGreetingState = - | { status: "pending" } - | { status: "generating"; attemptId: string } - | { status: "delivered"; messageId: string } - | { - status: "failed"; - retryable: boolean; - errorCode: PlannerGreetingErrorCode; - } - | { status: "skipped"; reason: "user-proceeded" }; - -export interface PlannerSessionMetadata { - identity: Extract; - greeting: PlannerGreetingState; - queuedInputIds: string[]; -} - -export interface PlannerQueuedInput { - id: string; - sessionId: string; - text: string; - acceptedAt: string; -} - -export interface PlannerSessionRequest { - mode: "resume-or-create" | "fresh"; - harness?: import("./types.js").HarnessKind; - theme?: import("./types.js").UiTheme; -} - -export interface PlannerSessionResponse { - session: import("./types.js").HarnessSession; - resolution: "created" | "live" | "resumed" | "rehydrated"; -} - -export interface PlannerMessageRequest { - text: string; -} - -/** Authoritative coordinator state returned after a planner mutation. */ -export interface PlannerSessionMetadataResponse { - metadata: PlannerSessionMetadata; -} - -/** - * Content-free planner lifecycle telemetry. Callers may persist these fields, - * but must never add prompts, assistant text, local paths, or provider errors. - */ -export type PlannerLifecycleEvent = - | { - name: "planner_session.created" | "planner_session.resumed"; - projectId: StudioProjectId; - sessionId: string; - resolution: PlannerSessionResponse["resolution"]; - } - | { - name: "planner_greeting.attempted" | "planner_greeting.retried"; - projectId: StudioProjectId; - sessionId: string; - attemptId: string; - queueDepth: number; - } - | { - name: "planner_greeting.delivered"; - projectId: StudioProjectId; - sessionId: string; - attemptId: string; - queueDepth: number; - } - | { - name: "planner_greeting.failed"; - projectId: StudioProjectId; - sessionId: string; - attemptId?: string; - errorCode: PlannerGreetingErrorCode; - retryable: boolean; - queueDepth: number; - } - | { - name: "planner_greeting.skipped"; - projectId: StudioProjectId; - sessionId: string; - attemptId?: string; - reason: "user-proceeded"; - queueDepth: number; - } - | { - name: "planner_session.input_delivery_uncertain"; - projectId: StudioProjectId; - sessionId: string; - inputId: string; - errorCode: "delivery_uncertain"; - queueDepth: number; - }; diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 596493871..92ab6078c 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -188,9 +188,9 @@ export interface HarnessSession { /** * The prior session this one was seeded from (portable continue — see * core/rehydration.ts), when a brief was ACTUALLY produced and delivered. - * For a trusted planner replacement, this instead names the exact prior - * HarnessSession whose durable coordinator FIFO was handed off; its brief - * may come from an older recorded ancestor in the same continuation chain. + * For a trusted replacement, this instead names the exact prior + * HarnessSession whose durable FIFO was handed off; its brief may come from + * an older recorded ancestor in the same continuation chain. * Null/absent otherwise, including an ordinary client request whose event * log contains no usable context. Absent on sessions persisted by builds * from before this existed. @@ -214,8 +214,6 @@ export interface HarnessSession { * answer the blocking prompt themselves. */ ready: boolean; - /** Trusted Studio-owned role metadata. Generic POST /sessions cannot set it. */ - planning?: import("./agent-map.js").PlannerSessionMetadata; /** Server-authored, path-free identity used only to revalidate MCP scope. */ agentMapIdentity?: import("./agent-map.js").PlanningSessionIdentity; } @@ -829,18 +827,26 @@ export type AnalyticsEventType = | "agent_map.workspace_read_failed" | "agent_map.mcp_tool" | "agent_map.capability" + /** + * Planner lifecycle types. SAP-3143 removed planner sessions, so nothing + * emits these any more; they stay in the union for one release so a + * consumer switching over AnalyticsEventType still compiles. + * @deprecated No longer emitted. Removed in the next minor. + */ | "planner_session.created" + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_session.resumed" + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_session.input_delivery_uncertain" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_greeting.attempted" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_greeting.delivered" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_greeting.failed" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_greeting.skipped" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** @deprecated No longer emitted. Removed in the next minor. */ | "planner_greeting.retried"; /** diff --git a/packages/harness/web/e2e/accumulation-guard.spec.ts b/packages/harness/web/e2e/accumulation-guard.spec.ts index e81a1da34..064814544 100644 --- a/packages/harness/web/e2e/accumulation-guard.spec.ts +++ b/packages/harness/web/e2e/accumulation-guard.spec.ts @@ -22,7 +22,6 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { openProjectMenu } from "./mock-navigation"; const ACME = "/Users/demo/acme-app"; @@ -98,54 +97,47 @@ test.describe("Remove project", () => { // A destructive action standing at full strength on every project row // would be the loudest thing in the rail; invisible even to the keyboard // would be worse. Both halves are CSS, so both are asserted on screen. - // Remove lives inside the row's ⋮ (SAP-2982); the session shortcut sits - // beside that menu and shares the same row-owned reveal contract. + // Remove is a row action of its own now, beside the session shortcut, and + // every action on the row shares one reveal contract — asserted across the + // whole set rather than a fixed count, so adding or removing a verb cannot + // quietly leave one of them standing. const row = page.getByTestId("workspace-group-acme-app").locator(":scope > .workspace-row"); const actions = row.locator(":scope > .workspace-row-action"); const opacities = (): Promise => actions.evaluateAll((elements) => elements.map((element) => getComputedStyle(element).opacity), ); + const count = await actions.count(); + expect(count).toBeGreaterThan(1); + const all = (value: string): string[] => Array(count).fill(value); - expect(await opacities()).toEqual(["0", "0"]); + expect(await opacities()).toEqual(all("0")); await row.hover(); - await expect.poll(opacities).toEqual(["1", "1"]); + await expect.poll(opacities).toEqual(all("1")); await page.mouse.move(0, 0); - await expect.poll(opacities).toEqual(["0", "0"]); - await actions.first().focus(); - await expect - .poll(() => - actions.first().evaluate((element) => getComputedStyle(element).opacity), - ) - .toBe("1"); - await actions.nth(1).focus(); - await expect - .poll(() => - actions.nth(1).evaluate((element) => getComputedStyle(element).opacity), - ) - .toBe("1"); - }); - - test("an OPEN menu holds its trigger on screen after the pointer leaves", async ({ page }) => { - // The popover is anchored to the ⋮. Letting the trigger fade back to - // opacity 0 when the pointer leaves the row leaves a card floating beside - // nothing — the anchor is invisible and the menu looks unmoored. - const menu = page.getByTestId("project-menu-acme-app"); - await menu.click(); - await page.mouse.move(0, 0); - await expect(page.getByTestId("project-menu-card-acme-app")).toBeVisible(); - await expect - .poll(() => menu.evaluate((element) => getComputedStyle(element).opacity)) - .toBe("1"); + await expect.poll(opacities).toEqual(all("0")); + // Keyboard reveal, one control at a time: a row action the pointer never + // touches must still show itself when it takes focus. + for (let index = 0; index < count; index += 1) { + await actions.nth(index).focus(); + await expect + .poll(() => + actions + .nth(index) + .evaluate((element) => getComputedStyle(element).opacity), + ) + .toBe("1"); + } }); - test("a COLLAPSED project row does not grow a standing ⋮", async ({ page }) => { + test("a COLLAPSED project row does not grow a standing remove", async ({ page }) => { // `.workspace-row.is-collapsed .workspace-row-action[aria-expanded]` tests - // only that the attribute is PRESENT, and a menu trigger always carries - // one — so without the exclusion in styles.css every collapsed project row - // wore a permanent ⋮, which is exactly the standing control the rail's - // hover-reveal exists to avoid. + // only that the attribute is PRESENT. The ⋮ this replaced always carried + // one, so every collapsed project row wore a permanent overflow until an + // exclusion was added for it. Plain row actions carry no `aria-expanded`, + // so the destructive one must stay hidden at rest on its own — the standing + // control the rail's hover-reveal exists to avoid. await page.getByTestId("project-disclosure-acme-app").click(); const row = page.getByTestId("workspace-group-acme-app").locator(":scope > .workspace-row"); await expect(row).toHaveClass(/is-collapsed/); @@ -153,7 +145,7 @@ test.describe("Remove project", () => { await expect .poll(() => page - .getByTestId("project-menu-acme-app") + .getByTestId("project-remove-acme-app") .evaluate((element) => getComputedStyle(element).opacity), ) .toBe("0"); @@ -162,7 +154,6 @@ test.describe("Remove project", () => { test("the confirm NAMES the number of sessions it ends, and says nothing on disk is touched", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); const confirm = page.getByTestId("remove-project-confirm"); await expect(confirm).toBeVisible(); @@ -179,7 +170,6 @@ test.describe("Remove project", () => { test("says so plainly when there is nothing to end", async ({ page }) => { // rfq-agent has one exited session and no live one. An abstract warning // here would be a lie in the only direction that matters. - await openProjectMenu(page, "rfq-agent"); await page.getByTestId("project-remove-rfq-agent").click(); await expect(page.getByTestId("remove-project-confirm-count")).toHaveText( "No running sessions to end.", @@ -187,7 +177,6 @@ test.describe("Remove project", () => { }); test("Keep project changes nothing", async ({ page }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); await page.getByRole("button", { name: "Keep project" }).click(); await expect(page.getByTestId("remove-project-confirm")).toHaveCount(0); @@ -203,7 +192,6 @@ test.describe("Remove project", () => { await expect(page.getByTestId("workflow-leasing")).toBeVisible(); expect(await recentDirPaths(page)).toContain(ACME); - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); await page.getByTestId("remove-project-confirm-btn").click(); @@ -247,7 +235,6 @@ test.describe("Remove project", () => { // into a hidden project would leave an agent that exists and nothing // shows; giving it a root of its own would mint `acme-app/leasing`, which // is the accumulation this ticket closes. - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); await page.getByTestId("remove-project-confirm-btn").click(); await expect(page.getByTestId("workspace-group-acme-app")).toHaveCount(0); @@ -271,7 +258,6 @@ test.describe("Remove project", () => { // so it cannot be confused with the `workers` subdirectory row inside it. await expect(page.getByTestId("workspace-group-polsia/services/workers")).toBeVisible(); - await openProjectMenu(page, "polsia"); await page.getByTestId("project-remove-polsia").click(); await page.getByTestId("remove-project-confirm-btn").click(); diff --git a/packages/harness/web/e2e/agent-map-planning.spec.ts b/packages/harness/web/e2e/agent-map.spec.ts similarity index 81% rename from packages/harness/web/e2e/agent-map-planning.spec.ts rename to packages/harness/web/e2e/agent-map.spec.ts index 38b6ce05c..08adcd520 100644 --- a/packages/harness/web/e2e/agent-map-planning.spec.ts +++ b/packages/harness/web/e2e/agent-map.spec.ts @@ -11,44 +11,57 @@ async function activeSessionId(page: Page): Promise { return page.getByTestId("session-context").getAttribute("data-session-id"); } -async function openPlannerSessionCallCount(page: Page): Promise { +async function createSessionCallCount(page: Page): Promise { return page.evaluate( () => ( window as unknown as { - __HARNESS_TEST__?: { openPlannerSessionCalls?: unknown[] }; + __HARNESS_TEST__?: { createSessionCalls?: unknown[] }; } - ).__HARNESS_TEST__?.openPlannerSessionCalls?.length ?? 0, + ).__HARNESS_TEST__?.createSessionCalls?.length ?? 0, ); } -test.describe("SAP-3058 Agent Map planning workspace", () => { +/** Opening a project starts nothing (SAP-3143); its first session is explicit. */ +async function startProjectSession(page: Page): Promise { + await page.getByTestId("project-session-start").click(); + await expect(page.locator(".harness-terminal")).toBeVisible(); +} + +test.describe("SAP-3058 Agent Map workspace", () => { test.beforeEach(async ({ page }) => { await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); }); - test("first open starts the raw planner CLI beside the honest empty map", async ({ + test("first open shows the map and starts NO session (SAP-3143)", async ({ page, }) => { + const before = await createSessionCallCount(page); await openDashboardMap(page); - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); + // The map renders from durable state, and the centre says honestly that + // nothing is running. No session was created by looking at a project. + await expect(page.getByTestId("project-session-empty")).toBeVisible(); await expect(page.getByTestId("agent-map-empty")).toHaveText( "Nothing generated yet", ); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + await page.waitForTimeout(500); + expect(await createSessionCallCount(page)).toBe(before); + // The explicit Start is what creates it, through the ordinary path. + await startProjectSession(page); + expect(await createSessionCallCount(page)).toBe(before + 1); const [cli, map] = await Promise.all([ - terminal.boundingBox(), + page.locator(".harness-terminal").boundingBox(), page.getByTestId("agent-map-empty").boundingBox(), ]); expect(cli?.width ?? 0).toBeGreaterThan(200); expect(map?.width ?? 0).toBeGreaterThan(200); expect(map?.x ?? 0).toBeGreaterThan((cli?.x ?? 0) + (cli?.width ?? 0) - 2); await page.screenshot({ - path: "web/e2e/screenshots/agent-map-planning.png", + path: "web/e2e/screenshots/agent-map.png", fullPage: true, }); @@ -75,7 +88,6 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { ); await expect(page.locator(".rail-workflows")).toBeVisible(); await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); await expect(page.getByTestId("agent-map-live")).toBeVisible({ timeout: 1_000, }); @@ -289,25 +301,28 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { await expect(frame).not.toHaveClass(/is-expanded/); }); - test("a generating greeting still renders the raw planner CLI", async ({ + test("the removed greeting fixtures no longer create a session on open", async ({ page, }) => { + // ?mockGreeting was planner-only. Whatever a stale link carries, opening a + // project shows the map and starts nothing. await page.goto( "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockGreeting=generating", ); await expect(page.locator(".rail-workflows")).toBeVisible(); await openDashboardMap(page); - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); + await expect(page.getByTestId("project-session-empty")).toBeVisible(); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + await startProjectSession(page); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); }); - test("return resumes the same planner and plus creates a fresh planner tab", async ({ + test("return keeps the project's own session and plus creates a second tab", async ({ page, }) => { await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); + await startProjectSession(page); const first = await activeSessionId(page); expect(first).toBeTruthy(); @@ -349,24 +364,23 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { await expect(page.locator(".harness-terminal")).toBeVisible(); }); - test("an explicitly selected planner tab wins over project resume ordering", async ({ + test("an explicitly selected session tab is not replaced by a new one", async ({ page, }) => { await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); + await startProjectSession(page); const first = await activeSessionId(page); expect(first).toBeTruthy(); await page.getByTestId("session-menu").click(); await page.getByTestId("session-rename").click(); const rename = page.getByTestId("session-rename-input"); - await rename.fill("Planner A"); + await rename.fill("Session A"); await rename.press("Enter"); await page.getByTestId("session-tab-new").click(); await expect.poll(() => activeSessionId(page)).not.toBe(first); - const callsBeforeExplicitSelection = - await openPlannerSessionCallCount(page); + const callsBeforeExplicitSelection = await createSessionCallCount(page); await page .getByTestId("workflow-dashboard-keeper") @@ -377,67 +391,57 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); await page.getByTestId("palette-trigger").click(); - await page.getByTestId("command-palette-input").fill("Planner A"); + await page.getByTestId("command-palette-input").fill("Session A"); await page .getByTestId("command-palette-list") - .getByText("Planner A", { exact: true }) + .getByText("Session A", { exact: true }) .click(); await expect(page.locator(".harness-terminal")).toBeVisible(); expect(await activeSessionId(page)).toBe(first); - await expect(page.getByTestId("planner-loading")).toHaveCount(0); - expect(await openPlannerSessionCallCount(page)).toBe( + expect(await createSessionCallCount(page)).toBe( callsBeforeExplicitSelection, ); }); - test("planner session chrome retains rename/end and omits path/editor actions", async ({ + test("a project session has the ordinary chrome, including path and editor", async ({ page, }) => { await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); + await startProjectSession(page); await page.getByTestId("session-menu").click(); const menu = page.getByTestId("session-menu-popover"); await expect(menu.getByTestId("session-rename")).toBeVisible(); await expect(menu.getByTestId("session-end-btn")).toBeVisible(); - await expect(menu.getByText("Copy path", { exact: true })).toHaveCount(0); - await expect(menu.getByTestId("session-open-editor")).toHaveCount(0); + // SAP-3143: it is an ordinary session, so it keeps the ordinary actions + // that the planner deliberately hid. + await expect(menu.getByText("Copy path", { exact: true })).toBeVisible(); + await expect(menu.getByTestId("session-open-editor")).toBeVisible(); }); - test("ending the planner exposes an immediate fresh-session path", async ({ + test("ending the last session returns to the honest empty state", async ({ page, }) => { await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); + await startProjectSession(page); const ended = await activeSessionId(page); await page.getByTestId("session-menu").click(); await page.getByTestId("session-end-btn").click(); - await expect(page.getByTestId("end-session-confirm")).toContainText( - "stops the planning conversation", - ); - await expect(page.getByTestId("end-session-confirm")).toContainText( - "fresh planning session from Plan Agents", - ); + // The planner-only copy is gone with the planner. await expect(page.getByTestId("end-session-confirm")).not.toContainText( - "live terminal", + "planning conversation", ); await page.getByTestId("end-session-confirm-btn").click(); - await expect(page.getByTestId("planner-session-ended")).toBeVisible(); - const startFresh = page.getByTestId("session-tab-new"); - await expect(startFresh).toHaveAttribute( - "aria-label", - "New planning session", - ); - await startFresh.click(); - - await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("project-session-empty")).toBeVisible(); + await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await startProjectSession(page); await expect.poll(() => activeSessionId(page)).not.toBe(ended); }); - test("a failed greeting still leaves the raw planner CLI visible", async ({ + test("a project with no session shows the map, not an error", async ({ page, }) => { await page.goto( @@ -446,12 +450,12 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { await expect(page.locator(".rail-workflows")).toBeVisible(); await openDashboardMap(page); - const terminal = page.locator(".harness-terminal"); - await expect(terminal).toBeVisible(); - await expect(terminal.locator(".xterm")).toBeVisible(); + await expect(page.getByTestId("project-session-empty")).toBeVisible(); + await expect(page.getByTestId("agent-map-empty")).toBeVisible(); + await expect(page.getByTestId("agent-map-load-error")).toHaveCount(0); }); - test("workspace and planner failures stay local, while unauthorized is whole-workspace", async ({ + test("a map read failure is local, while unauthorized is whole-workspace", async ({ page, }) => { await page.goto( @@ -459,8 +463,9 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { ); await expect(page.locator(".rail-workflows")).toBeVisible(); await openDashboardMap(page); - await expect(page.locator(".harness-terminal")).toBeVisible(); await expect(page.getByTestId("agent-map-load-error")).toBeVisible(); + // The centre is unaffected: the project can still start a session. + await expect(page.getByTestId("project-session-empty")).toBeVisible(); await expect .poll(() => page.evaluate(() => @@ -484,36 +489,6 @@ test.describe("SAP-3058 Agent Map planning workspace", () => { ) .toBe(true); - await page.goto( - "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockPlanner=error", - ); - await expect(page.locator(".rail-workflows")).toBeVisible(); - await openDashboardMap(page); - await expect(page.getByTestId("planner-load-error")).toBeVisible(); - await expect(page.getByTestId("agent-map-empty")).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => - ( - ( - window as unknown as { - __HARNESS_TEST__?: { - trackEvents?: Array<{ - event: string; - data?: Record; - }>; - }; - } - ).__HARNESS_TEST__?.trackEvents ?? [] - ).some( - (event) => - event.event === "agent_map.workspace_load_failed" && - event.data?.pane === "planner", - ), - ), - ) - .toBe(true); - await page.goto( "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapWorkspace=unauthorized", ); @@ -535,6 +510,7 @@ test.describe("SAP-3058 mobile Agent Map", () => { await expect(page.getByTestId("rail-expand")).toBeVisible(); await page.getByTestId("rail-expand").click(); await openDashboardMap(page); + await startProjectSession(page); await expect(page.locator(".harness-terminal")).toBeVisible(); await expect(page.locator(".right-pane")).toBeHidden(); @@ -557,7 +533,7 @@ test.describe("SAP-3058 mobile Agent Map", () => { await expect(page.locator(".right-pane")).toBeHidden(); await expect(page.getByTestId("right-expand")).toBeFocused(); await page.screenshot({ - path: "web/e2e/screenshots/agent-map-planning-mobile.png", + path: "web/e2e/screenshots/agent-map-mobile.png", fullPage: true, }); }); @@ -581,13 +557,15 @@ test.describe("SAP-3058 mobile Agent Map", () => { await newSession.click(); await expect.poll(() => activeSessionId(page)).not.toBe(first); - // Open the sheet while the new planner is still launching. Its automatic + // Open the sheet while the new session is still launching. Its automatic // ready/status event arrives later and must not replay the failed preference // restore or collapse the user-opened Agent Map. await page.getByTestId("right-expand").click(); await expect(page.locator(".right-pane")).toBeVisible(); + // The strip now shows the project's ordinary sessions, so more than one + // may be running; one live dot is enough to prove a session update landed. await expect( - page.locator(".session-dot[data-status='running']"), + page.locator(".session-dot[data-status='running']").first(), ).toBeVisible(); await expect(page.locator(".right-pane")).toBeVisible(); }); diff --git a/packages/harness/web/e2e/create-agent.spec.ts b/packages/harness/web/e2e/create-agent.spec.ts index 5cfbb8979..5fcfdf655 100644 --- a/packages/harness/web/e2e/create-agent.spec.ts +++ b/packages/harness/web/e2e/create-agent.spec.ts @@ -2,7 +2,7 @@ * SAP-2981 — the legacy-server create-agent compatibility flow. * * Current Studio servers return durable project summaries and route creation - * through Agent Map planning. These specs deliberately use + * through the Agent Map. These specs deliberately use * `mockStudioProjects=absent` to protect clients connected to older servers * whose state payloads have no Studio project catalog. * @@ -26,7 +26,6 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { openProjectMenu } from "./mock-navigation"; const ROOT = "/Users/demo/acme-app"; @@ -59,7 +58,6 @@ test.describe("legacy-server agent creation compatibility", () => { test("the menu opens a dialog that STATES the project, and starts nothing", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); const dialog = page.getByTestId("create-agent-dialog"); @@ -81,7 +79,6 @@ test.describe("legacy-server agent creation compatibility", () => { }); test("creation completes BEFORE the session starts", async ({ page }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); await page.getByTestId("create-agent-name").fill("billing-bot"); await page.getByTestId("create-agent-submit").click(); @@ -102,7 +99,6 @@ test.describe("legacy-server agent creation compatibility", () => { test("a first instruction reaches the session, and never asks for a scaffold", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); await page.getByTestId("create-agent-name").fill("digest-bot"); await page @@ -125,7 +121,6 @@ test.describe("legacy-server agent creation compatibility", () => { test("a duplicate name is refused by the SERVER, in the dialog, and nothing starts", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); // `leasing` is a fixture agent in this project. The field has no opinion // about it — only the endpoint knows what is already there. @@ -150,7 +145,6 @@ test.describe("legacy-server agent creation compatibility", () => { test("a name that is not one folder segment is refused before it is sent", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); const name = page.getByTestId("create-agent-name"); const submit = page.getByTestId("create-agent-submit"); @@ -181,7 +175,6 @@ test.describe("legacy-server agent creation compatibility", () => { test("Return submits from the name field — and Return on Cancel cancels", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); await page.getByTestId("create-agent-name").fill("returned"); await page.getByTestId("create-agent-name").press("Enter"); @@ -190,7 +183,6 @@ test.describe("legacy-server agent creation compatibility", () => { // The dialog took Return for the whole form, so a focused Cancel took it // too: pressing Return on "Cancel" closed the dialog AND created the // agent — the opposite of what was pressed. Measured, before the guard. - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); await page.getByTestId("create-agent-name").fill("cancelled"); await page.getByRole("button", { name: "Cancel" }).focus(); diff --git a/packages/harness/web/e2e/dialog-shell.spec.ts b/packages/harness/web/e2e/dialog-shell.spec.ts index 263de90a0..8f985915a 100644 --- a/packages/harness/web/e2e/dialog-shell.spec.ts +++ b/packages/harness/web/e2e/dialog-shell.spec.ts @@ -18,7 +18,6 @@ import { expect, test } from "@playwright/test"; import type { Locator, Page } from "@playwright/test"; -import { openProjectMenu } from "./mock-navigation"; interface DialogCase { name: string; @@ -67,12 +66,11 @@ const CASES: DialogCase[] = [ open: async (page) => { await page.goto("/"); await expect(page.locator(".rail-workflows")).toBeVisible(); - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); await expect(page.getByTestId("remove-project-confirm")).toBeVisible(); }, surface: (page) => page.getByTestId("remove-project-confirm"), - trigger: (page) => page.getByTestId("project-menu-acme-app"), + trigger: (page) => page.getByTestId("project-remove-acme-app"), // The SAFE action, on a destructive dialog: Enter keeps the project. opensFocusedOn: (page) => page.getByRole("button", { name: "Keep project" }), behind: (page) => page.getByTestId("rail-create-new"), @@ -82,11 +80,13 @@ const CASES: DialogCase[] = [ open: async (page) => { await page.goto("/?seed=0&mockStudioProjects=absent"); await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); await expect(page.getByTestId("create-agent-dialog")).toBeVisible(); }, surface: (page) => page.getByTestId("create-agent-dialog"), + // The row action survives the dialog now. It used to be a menu item that + // unmounted with its popover, so focus had nowhere to go but the document. + trigger: (page) => page.getByTestId("project-create-agent-acme-app"), opensFocusedOn: (page) => page.getByTestId("create-agent-name"), behind: (page) => page.getByTestId("rail-create-new"), }, diff --git a/packages/harness/web/e2e/mock-navigation.ts b/packages/harness/web/e2e/mock-navigation.ts index fe7420293..5587fd206 100644 --- a/packages/harness/web/e2e/mock-navigation.ts +++ b/packages/harness/web/e2e/mock-navigation.ts @@ -37,16 +37,3 @@ export async function selectMockSessionFromPalette( await item.click(); } -/** - * Open a project row's ⋮ menu. - * - * Every action a project row offers now lives behind one control (SAP-2982). - * `+` and `×` used to sit on the row itself — adjacent, same size, same - * hover-reveal — while acting on different nouns: `+` created an AGENT in the - * project, `×` removed the PROJECT. A menu of named items has no adjacency to - * misread, and the specs open it before acting. - */ -export async function openProjectMenu(page: Page, label: string): Promise { - await page.getByTestId(`project-menu-${label}`).click(); - await expect(page.getByTestId(`project-menu-card-${label}`)).toBeVisible(); -} diff --git a/packages/harness/web/e2e/new-session-composer.spec.ts b/packages/harness/web/e2e/new-session-composer.spec.ts index b04986a1f..0cbbde567 100644 --- a/packages/harness/web/e2e/new-session-composer.spec.ts +++ b/packages/harness/web/e2e/new-session-composer.spec.ts @@ -39,7 +39,6 @@ const sessionEvidence = ( injectInputCalls: number; injectedSessionId: string | null; injectedText: string; - openPlannerSessionCalls: number; }> => page.evaluate(() => { const testState = ( @@ -48,7 +47,6 @@ const sessionEvidence = ( createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; lastInjectInput?: { id?: string; req?: { text?: string } }; - openPlannerSessionCalls?: unknown[]; }; } ).__HARNESS_TEST__; @@ -61,7 +59,6 @@ const sessionEvidence = ( injectInputCalls: testState?.injectInputCalls?.length ?? 0, injectedSessionId: testState?.lastInjectInput?.id ?? null, injectedText: testState?.lastInjectInput?.req?.text ?? "", - openPlannerSessionCalls: testState?.openPlannerSessionCalls?.length ?? 0, }; }); @@ -119,7 +116,6 @@ test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents const before = await sessionEvidence(page); expect(before.activeSessionId).toBeNull(); expect(before.createSessionCalls).toBe(0); - expect(before.openPlannerSessionCalls).toBe(0); await page.getByTestId("rail-create-new").click(); const idea = "Build a sales outreach agent."; @@ -132,8 +128,9 @@ test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents .toContain(idea); const evidence = await sessionEvidence(page); + // Exactly one session: the standalone builder. Selecting Plan Agents below + // must not add a second one (SAP-3143 removed the planner launch). expect(evidence.createSessionCalls).toBe(before.createSessionCalls + 1); - expect(evidence.openPlannerSessionCalls).toBe(before.openPlannerSessionCalls); expect(evidence.injectedSessionId).not.toBeNull(); expect(evidence.activeSessionId).toBe(evidence.injectedSessionId); expect(evidence.activeSessionId).not.toBe(before.activeSessionId); @@ -146,26 +143,18 @@ test("Enter keeps a new-agent prompt in its standalone builder until Plan Agents await expect(planAgents).toHaveAttribute("aria-pressed", "false"); await planAgents.click(); - await expect - .poll(async () => (await sessionEvidence(page)).openPlannerSessionCalls) - .toBe(before.openPlannerSessionCalls + 1); await expect(planAgents).toHaveAttribute("aria-pressed", "true"); + await page.waitForTimeout(500); + expect((await sessionEvidence(page)).createSessionCalls).toBe( + evidence.createSessionCalls, + ); }); -test("returning to an in-progress standalone builder does not restore Plan Agents", async ({ +test("returning to an in-progress standalone builder starts no new session", async ({ page, }) => { await page.goto("/?seed=0&mockStudioProjects=present"); await expect(page.locator(".rail-workflows")).toBeVisible(); - await expect - .poll(async () => (await sessionEvidence(page)).openPlannerSessionCalls) - .toBeGreaterThan(0); - await expect - .poll(async () => { - const evidence = await sessionEvidence(page); - return evidence.createSessionCalls - evidence.openPlannerSessionCalls; - }) - .toBe(0); const before = await sessionEvidence(page); await page.getByTestId("rail-create-new").click(); @@ -192,7 +181,7 @@ test("returning to an in-progress standalone builder does not restore Plan Agent .poll(async () => (await sessionEvidence(page)).injectedText) .toContain(idea); // Let the session we deliberately visited finish its own normal restore; - // only planner work caused by returning to the builder is under test. + // only session work caused by returning to the builder is under test. await page.waitForTimeout(500); const beforeReturn = await sessionEvidence(page); @@ -204,9 +193,7 @@ test("returning to an in-progress standalone builder does not restore Plan Agent expect((await sessionEvidence(page)).activeSessionId).not.toBe(awaySessionId); await page.waitForTimeout(500); const afterReturn = await sessionEvidence(page); - expect(afterReturn.openPlannerSessionCalls).toBe( - beforeReturn.openPlannerSessionCalls, - ); + expect(afterReturn.createSessionCalls).toBe(beforeReturn.createSessionCalls); await expect( page .getByTestId("workspace-group-acme-app/projects/build-revisit-guard") diff --git a/packages/harness/web/e2e/open-project.spec.ts b/packages/harness/web/e2e/open-project.spec.ts index a3af716e5..40325b988 100644 --- a/packages/harness/web/e2e/open-project.spec.ts +++ b/packages/harness/web/e2e/open-project.spec.ts @@ -21,7 +21,6 @@ */ import { expect, test } from "@playwright/test"; -import { openProjectMenu } from "./mock-navigation"; /* A folder that is NOTHING yet: no agent, no session, no recentDirs entry. `scratch` cannot play this part — it is the fixture's bare-session project, @@ -88,7 +87,7 @@ test.describe("the header + opens a project", () => { await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); }); - test("a planning project keeps sessions direct while hiding standalone agent creation", async ({ + test("a Studio project starts nothing on open and hides standalone agent creation", async ({ page, }) => { await page.getByTestId("rail-add-project").click(); @@ -97,36 +96,26 @@ test.describe("the header + opens a project", () => { const group = page.getByTestId("workspace-group-blank-slate"); await expect(group.getByTestId("agent-map-row")).toBeVisible(); - await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); - - // Once the initial planner is no longer a live bare session, the old rail - // exposed its standalone-agent shortcut again. Ending it reproduces the - // stable empty-project state from the reported sidebar. - await page.getByTestId("session-menu").click(); - await page.getByTestId("session-end-btn").click(); - await page.getByTestId("end-session-confirm-btn").click(); - await expect(page.getByTestId("planner-session-ended")).toBeVisible(); + // SAP-3143: opening a project shows its map and creates no session. + await expect(page.getByTestId("project-session-empty")).toBeVisible(); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + // D36: an empty project gets no create ROW of its own — its Agent Map row + // is the CTA. The row's `+` is a different control and is always there. await expect(group.getByTestId("project-empty-blank-slate")).toHaveCount(0); - await expect( - group.getByTestId("project-start-session-blank-slate"), - ).toHaveAttribute("aria-label", "Start a session in blank-slate"); - // The visible empty row and the project menu used to be two doors into the - // same direct-create flow. A planning project exposes neither: generated - // agents enter through an approved map, while project removal remains an - // ordinary project-level action. - await openProjectMenu(page, "blank-slate"); + // New agent, scoped to this project, on the row itself (IA.md 219, D34a). + // This used to be suppressed on every Studio project on the grounds that + // the pinned Agent Map owned creation — but the map's only route was the + // planner session SAP-3143 deletes, so the capability had no door left. await expect( page.getByTestId("project-create-agent-blank-slate"), - ).toHaveCount(0); + ).toHaveAttribute("aria-label", "Create an agent in blank-slate"); await expect(page.getByTestId("project-remove-blank-slate")).toBeVisible(); await page.keyboard.press("Escape"); - // The same ownership rule covers a bare project with an existing session: - // its former in-session scaffold action cannot bypass planning either. - await openProjectMenu(page, "scratch"); - await expect(page.getByTestId("workspace-scaffold-scratch")).toHaveCount(0); + // A bare project keeps the scaffold verb, distinct from creating anew. + await expect(page.getByTestId("workspace-scaffold-scratch")).toBeVisible(); await expect(page.getByTestId("project-remove-scratch")).toBeVisible(); // NOT the rail-wide empty state leaking down: that one says "No agents yet" @@ -243,7 +232,6 @@ test.describe("round trip: removed, then back", () => { const before = await projectRows(page); expect(before).toContain("project-row-acme-app"); - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); await page.getByTestId("remove-project-confirm-btn").click(); await expect(page.getByTestId("project-row-acme-app")).toHaveCount(0); @@ -285,7 +273,6 @@ test.describe("round trip: removed, then back", () => { test("opening a folder ABOVE a removed project un-hides what is inside it", async ({ page, }) => { - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-remove-acme-app").click(); await page.getByTestId("remove-project-confirm-btn").click(); await expect(page.getByTestId("workflow-leasing")).toHaveCount(0); diff --git a/packages/harness/web/e2e/project-axis.spec.ts b/packages/harness/web/e2e/project-axis.spec.ts index b34df84c6..0122fbcfc 100644 --- a/packages/harness/web/e2e/project-axis.spec.ts +++ b/packages/harness/web/e2e/project-axis.spec.ts @@ -165,7 +165,7 @@ test.describe("ordering", () => { }); test.describe("the plan-first project children", () => { - test("the project plus starts a coding session at its root without creating an agent", async ({ + test("the row's plus is New agent; a coding session starts from the project's own pane", async ({ page, }) => { await page.evaluate(() => { @@ -181,13 +181,14 @@ test.describe("the plan-first project children", () => { }); const group = page.getByTestId("workspace-group-dashboard-keeper"); const row = group.getByTestId("project-row-dashboard-keeper"); - const start = group.getByTestId("project-start-session-dashboard-keeper"); + const create = group.getByTestId("project-create-agent-dashboard-keeper"); - await expect(start).toHaveAttribute( + await expect(create).toHaveAttribute( "aria-label", - "Start a session in dashboard-keeper", + "Create an agent in dashboard-keeper", ); - await expect(start).toHaveAttribute("data-tooltip", "Start a session here"); + // A plain session is NOT a row verb: it starts from the tab strip or from + // the Start on the project's own pane (D34e, D35 item 6). expect( await row .locator(":scope > .workspace-row-action") @@ -195,19 +196,21 @@ test.describe("the plan-first project children", () => { actions.map((action) => action.getAttribute("data-testid")), ), ).toEqual([ - "project-start-session-dashboard-keeper", - "project-menu-dashboard-keeper", + "project-create-agent-dashboard-keeper", + "project-remove-dashboard-keeper", ]); - // Prove the shortcut also works from map altitude: the new generic session - // becomes the visible workbench, while the planner remains resumable from - // Plan Agents and no scaffold request is made. + // At map altitude the project's pane offers the Start (SAP-3143). The new + // generic session becomes the visible workbench, rooted at the project and + // on the preferred harness, and no scaffold request is made. The map row + // STAYS selected: the session opens in the centre without changing which + // rail row you are standing on. await group.getByTestId("agent-map-select").click(); await expect(group.getByTestId("agent-map-select")).toHaveAttribute( "aria-pressed", "true", ); - await start.click(); + await page.getByTestId("project-session-start").click(); await expect .poll(() => page.evaluate( @@ -234,7 +237,7 @@ test.describe("the plan-first project children", () => { }); await expect(group.getByTestId("agent-map-select")).toHaveAttribute( "aria-pressed", - "false", + "true", ); await expect(page.getByTestId("session-context-title")).toContainText( "dashboard-keeper", @@ -259,14 +262,15 @@ test.describe("the plan-first project children", () => { const map = group.getByTestId("agent-map-select"); await map.click(); await expect(map).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator(".harness-terminal")).toBeVisible(); + // Opening the map starts nothing (SAP-3143); the centre offers the Start. + await expect(page.getByTestId("project-session-empty")).toBeVisible(); await page.evaluate(() => { ( window as unknown as { __MOCK_CREATE_SESSION_FAIL_ONCE__?: boolean } ).__MOCK_CREATE_SESSION_FAIL_ONCE__ = true; }); - await group.getByTestId("project-start-session-dashboard-keeper").click(); + await page.getByTestId("project-session-start").click(); await expect(page.getByTestId("toast")).toContainText( "mock: couldn't create session", ); diff --git a/packages/harness/web/e2e/rail-grammar.spec.ts b/packages/harness/web/e2e/rail-grammar.spec.ts index 94a8c2f8b..d0911f6fc 100644 --- a/packages/harness/web/e2e/rail-grammar.spec.ts +++ b/packages/harness/web/e2e/rail-grammar.spec.ts @@ -19,7 +19,6 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { openProjectMenu } from "./mock-navigation"; const ROW = (page: Page, label: string) => page @@ -34,57 +33,52 @@ test.describe("legacy-server project row grammar", () => { await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible(); }); - test("a session shortcut sits immediately before the named project menu", async ({ + test("the row's verbs are hover actions, each naming its own subject", async ({ page, }) => { const row = ROW(page, "acme-app"); - // The frequent session action is one click away. Destructive project - // management remains behind the named overflow menu instead of returning - // as an adjacent `×`. + // Hover actions, not an overflow menu (design-eng D33). The `+` is New + // agent (IA.md 219, D34a); the destructive one is last. The adjacency + // SAP-2982 worried about is answered by the accessible name and the + // confirmation, not by hiding one verb behind a popover. A plain session is + // not a row verb at all — the tab strip owns it (D34e). const actions = row.locator(".workspace-row-action"); await expect(actions).toHaveCount(2); await expect(actions.nth(0)).toHaveAttribute( "data-testid", - "project-start-session-acme-app", - ); - await expect(actions.nth(0)).toHaveAttribute( - "aria-label", - "Start a session in acme-app", + "project-create-agent-acme-app", ); await expect(actions.nth(1)).toHaveAttribute( "data-testid", - "project-menu-acme-app", + "project-remove-acme-app", ); - // And the actions themselves state their subject in words. - await openProjectMenu(page, "acme-app"); - await expect(page.getByTestId("project-create-agent-acme-app")).toHaveText( - "Create an agent in acme-app", - ); - await expect(page.getByTestId("project-remove-acme-app")).toHaveText( + // Each states its subject where a glyph cannot: the accessible name. + await expect( + page.getByTestId("project-create-agent-acme-app"), + ).toHaveAttribute("aria-label", "Create an agent in acme-app"); + await expect(page.getByTestId("project-remove-acme-app")).toHaveAttribute( + "aria-label", "Remove acme-app from the rail", ); }); - test("a bare project's session shortcut stays distinct from scaffolding", async ({ + test("a bare project's create verb is scaffold, and says so", async ({ page, }) => { - // `scratch` has live sessions and no Sapiom agent. The row `+` can start - // another coding session; the legacy scaffold operation remains named in - // the menu so the two operations do not masquerade as one another. + // `scratch` has live sessions and no Sapiom agent, so its create verb is + // SCAFFOLD — it grows an agent inside the session already running there + // rather than starting a new one. The distinct glyph and the distinct name + // are what keep it from reading as the ordinary create. const row = ROW(page, "scratch"); await expect(row.locator(".workspace-row-action")).toHaveCount(2); await expect( - page.getByTestId("project-start-session-scratch"), - ).toBeVisible(); - await openProjectMenu(page, "scratch"); - await expect(page.getByTestId("workspace-scaffold-scratch")).toHaveText( - "Scaffold an agent in scratch", - ); + page.getByTestId("workspace-scaffold-scratch"), + ).toHaveAttribute("aria-label", "Scaffold an agent in scratch"); await expect(page.getByTestId("project-remove-scratch")).toBeVisible(); }); - test("creating from the menu creates IN that project, and only then talks", async ({ + test("creating from the row creates IN that project, and only then talks", async ({ page, }) => { // The menu changed what the control SAYS; SAP-2981 changed what it does — @@ -108,9 +102,7 @@ test.describe("legacy-server project row grammar", () => { ).__HARNESS_TEST__?.createOrder ?? []) as string[], ); - await openProjectMenu(page, "acme-app"); await page.getByTestId("project-create-agent-acme-app").click(); - await expect(page.getByTestId("project-menu-card-acme-app")).toHaveCount(0); await expect(page.getByTestId("create-agent-project")).toHaveText( "acme-app", ); diff --git a/packages/harness/web/e2e/smoke.spec.ts b/packages/harness/web/e2e/smoke.spec.ts index cc2eb83e5..b026f3b1e 100644 --- a/packages/harness/web/e2e/smoke.spec.ts +++ b/packages/harness/web/e2e/smoke.spec.ts @@ -13,7 +13,6 @@ import type { Page } from "@playwright/test"; import { focusRfqAgent, - openProjectMenu, selectMockSessionFromPalette, } from "./mock-navigation"; @@ -359,7 +358,7 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { }) => { // This assertion preserves the scaffold affordance for older state // payloads without a durable Studio project catalog. Current Studio - // projects intentionally expose only Agent Map planning. + // projects intentionally expose only the Agent Map row. await page.goto("/?seed=0&mockStudioProjects=absent"); await expect(page.locator(".rail-workflows")).toBeVisible(); @@ -404,7 +403,6 @@ test.describe("three-zone IA (rail explorer, tab strip, right pane)", () => { // The scaffold action moved into the row's ⋮ with every other project // action (SAP-2982): a Sparkles glyph acting on an AGENT sat adjacent to // an `×` acting on the PROJECT, same size, same reveal. - await openProjectMenu(page, "scratch"); await expect(page.getByTestId("workspace-scaffold-scratch")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByTestId("workspace-focus-scratch")).toHaveCount(0); diff --git a/packages/harness/web/e2e/unrooted-agents.spec.ts b/packages/harness/web/e2e/unrooted-agents.spec.ts index 65f651a67..ec5428e1c 100644 --- a/packages/harness/web/e2e/unrooted-agents.spec.ts +++ b/packages/harness/web/e2e/unrooted-agents.spec.ts @@ -21,7 +21,6 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { openProjectMenu } from "./mock-navigation"; const ORCHESTRATION = "/Users/demo/design-eng/ari/orchestration"; const FIX_ORCHESTRATION = "/Users/demo/design-eng-fix/ari/orchestration"; @@ -426,7 +425,6 @@ test.describe("(c) there is a way OUT", () => { // removal whose rows merely move somewhere else has renamed the project, // not removed it — `accumulation-guard.spec.ts` pins that), so the count // does NOT climb here. - await openProjectMenu(page, "design-eng"); await page.getByTestId("project-remove-design-eng").click(); await page.getByTestId("remove-project-confirm-btn").click(); await expect(page.getByTestId("project-row-design-eng")).toHaveCount(0); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 340891cd1..8a7147faa 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -58,7 +58,6 @@ import type { } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; import type { - PlannerSessionRequest, StudioProjectId, StudioWorkspaceSelection, } from "@shared/agent-map"; @@ -121,7 +120,7 @@ import { secretsDisabledReason, type ProjectRef, } from "./lib/canvas-altitude"; -import { mostSpecificStudioScope } from "./lib/agent-map"; +import { mostSpecificStudioScope, studioProjectRoot } from "./lib/agent-map"; import { inputContractFromCanvasGraph } from "./lib/run-input"; import { agentUrl } from "./lib/urls"; import { @@ -152,7 +151,7 @@ import { directActionKind } from "./lib/macro-actions"; import { describeWorkflowPrompt } from "./lib/describe-prompt"; import { sessionDisplayName } from "./lib/session-name"; import type { PaletteAction } from "./lib/palette"; -import { getTheme, toggleTheme } from "./lib/theme"; +import { toggleTheme } from "./lib/theme"; import { loadUiPrefs, saveUiPrefs } from "./lib/ui-prefs"; import { useNavigationHistory, @@ -305,59 +304,18 @@ export const App = (): JSX.Element => { ); const restoredStudioProjectsRef = useRef(new Set()); const studioRestoreGenerationRef = useRef(0); - const plannerProjectId = + const agentMapProjectId = studioSelection?.kind === "agent-map" ? studioSelection.projectId : null; - const handlePlannerReady = useCallback( - ( - response: { session: HarnessSession }, - mode: PlannerSessionRequest["mode"], - ): void => { - const selected = harness.state?.sessions.find( - (session) => session.id === harness.activeSessionId, - ); - // An explicit palette/history selection is more specific than the - // project-level resume ordering. Keep that chosen live tab; fresh mode - // remains an explicit request to select the newly-created planner. - if ( - mode === "resume-or-create" && - selected?.status !== "exited" && - selected?.planning?.identity.role === "map-planner" && - selected.planning.identity.projectId === - response.session.planning?.identity.projectId - ) { - return; - } - harness.setActiveSessionId(response.session.id); - }, - [ - harness.activeSessionId, - harness.setActiveSessionId, - harness.state?.sessions, - ], - ); - const activePlannerForProject = harness.state?.sessions.find( - (session) => - session.id === harness.activeSessionId && - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === plannerProjectId, - ); const agentMapEntry = useAgentMapEntry({ - projectId: plannerProjectId, - selectedPlanner: activePlannerForProject ?? null, + projectId: agentMapProjectId, api: harness.api, - harness: () => - loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code", - theme: getTheme, - openPlannerSession: harness.openPlannerSession, - onPlannerReady: handlePlannerReady, subscribeProposalChanges: harness.subscribeAgentMapProposalChanges, subscribeReconnects: harness.subscribeEventReconnects, }); // A project visit restores its server-owned preference before choosing an - // altitude. Once map is chosen, `useAgentMapEntry` owns the independent map - // and planner requests; preference restoration must not couple their fate. + // altitude. Once map is chosen, `useAgentMapEntry` reads the map from + // durable state; it starts no session (SAP-3143). useEffect(() => { const state = harness.state; const active = state?.sessions.find( @@ -875,17 +833,9 @@ export const App = (): JSX.Element => { knownRootsOf(harness.settings?.recentDirs, harness.state?.launchDir), ); const tabs = - studioSelection?.kind === "agent-map" - ? sessions.filter( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === - studioSelection.projectId, - ) - : subject.kind === "project" - ? liveSessionsForProject(sessions, subject.root) - : liveSessionsForFocus(sessions, subject.path); + subject.kind === "project" + ? liveSessionsForProject(sessions, subject.root) + : liveSessionsForFocus(sessions, subject.path); const target = tabs[Number(e.key) - 1]; if (target) { e.preventDefault(); @@ -1392,21 +1342,36 @@ export const App = (): JSX.Element => { const planningWorkspace = studioView?.altitude === "map"; const agentMapUnavailable = planningWorkspace && agentMapEntry.state.unavailable !== null; - const plannerSessions = planningWorkspace - ? state.sessions.filter( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === studioView.projectId, - ) - : []; - const activePlannerSession = + // The selected Studio project's root, from the one shared rule so the tab + // strip and the Start target cannot key to different folders when a project + // has nested bound scopes. + // + // `conversation` below deliberately keys to `selectedStudioScope` (the + // INNERMOST scope containing the focused agent) instead: the tabs belong to + // the project, while the conversation subject follows what the user is + // looking at. The two answers differ only under nested scopes, and that + // difference is the point. + const selectedStudioRoot = studioProjectRoot(selectedStudioScopes); + const projectSessions = + planningWorkspace && selectedStudioRoot + ? liveSessionsForProject(state.sessions, selectedStudioRoot) + : []; + const activeProjectSession = planningWorkspace && - activeSession?.status !== "exited" && - activeSession?.planning?.identity.role === "map-planner" && - activeSession.planning.identity.projectId === studioView.projectId + activeSession && + activeSession.status !== "exited" && + projectSessions.some((session) => session.id === activeSession.id) ? activeSession : null; + const startProjectSessionFromMap = + planningWorkspace && selectedStudioRoot + ? () => + void startProjectSession( + selectedStudioRoot, + selectedStudioProject?.displayName ?? basenameOf(selectedStudioRoot), + preferredHarness(), + ) + : null; /** * Whose tabs the strip shows: the ACTIVE session's PROJECT (SAP-2980), never @@ -1428,7 +1393,7 @@ export const App = (): JSX.Element => { knownProjectRoots(), ); const focusTabs = planningWorkspace - ? plannerSessions + ? projectSessions : conversation.kind === "project" ? liveSessionsForProject(state.sessions, conversation.root) : liveSessionsForFocus(state.sessions, conversation.path); @@ -1512,7 +1477,7 @@ export const App = (): JSX.Element => { const rightPaneSuppressedByComposer = (showComposer && !atMapAltitude) || agentMapUnavailable; const sessionBarSession = planningWorkspace - ? activePlannerSession + ? activeProjectSession : showWorkbench || showDead ? activeSession : null; @@ -1677,13 +1642,6 @@ export const App = (): JSX.Element => { setTemplatesOpen(false); setOverviewOpen(false); closeMobileDrawer(); - // Stable Studio projects talk through their trusted map-planner. The - // selection effect starts resume-or-create; an ordinary project-root PTY - // here would race it and briefly make the wrong conversation authoritative. - if (selectedAgentMap) { - if (isMobile) setRightCollapsed(true); - return; - } const decision = sessionForFocus({ focusPath: root, active: activeSession, @@ -1696,6 +1654,13 @@ export const App = (): JSX.Element => { harness.setActiveSessionId(decision.to.id); return; } + // A Studio project shows its map from durable state and starts nothing + // (SAP-3143). Its first session is the user's explicit Start, which goes + // through the same createSession path as the + tab. + if (selectedAgentMap) { + if (isMobile) setRightCollapsed(true); + return; + } void startProjectSession(root, label, preferredHarness()); }; selectProjectRef.current = handleSelectWorkspace; @@ -2245,24 +2210,6 @@ export const App = (): JSX.Element => { setTemplatesOpen(false); setOverviewOpen(false); const session = state.sessions.find((s) => s.id === id); - if (session?.planning?.identity.role === "map-planner") { - const selection: StudioWorkspaceSelection = { - kind: "agent-map", - projectId: session.planning.identity.projectId, - }; - restoredStudioProjectsRef.current.add(selection.projectId); - setStudioSelection(selection); - setSelectedProject(null); - setFocusedAgentPath(session.cwd); - closeMobileDrawer(); - if (isMobile) setRightCollapsed(true); - harness.setActiveSessionId(id); - void harness.api.putStudioCurrentWorkspace( - selection.projectId, - selection, - ); - return; - } // Opening one of the selected project's own sessions is not a navigation // away from it — only a session somewhere else is. leaveProjectUnlessInside(session?.cwd ?? null); @@ -2850,21 +2797,6 @@ export const App = (): JSX.Element => { }} launchDir={state.launchDir ?? null} listDir={harness.listDir} - onStartProjectSession={async (root, label) => { - // A project-row `+` explicitly asks to see a fresh coding - // session, even when Plan Agents or a legacy project map is the - // current altitude. Change views only after creation succeeds so - // a failed launch leaves the planner in place and resumable. - const started = await startProjectSession( - root, - label, - preferredHarness(), - ); - if (!started) return; - studioRestoreGenerationRef.current += 1; - setStudioSelection(null); - setSelectedProject(null); - }} listHarnesses={harness.listHarnesses} onCreateAgent={handleCreateAgentInProject} onScaffoldInSession={handleScaffoldInSession} @@ -3001,7 +2933,6 @@ export const App = (): JSX.Element => {
{ } sessions={ planningWorkspace - ? plannerSessions + ? projectSessions : showWorkbench ? focusTabs : [] @@ -3061,12 +2992,12 @@ export const App = (): JSX.Element => { } newSessionPending={ planningWorkspace - ? agentMapEntry.state.planner.status === "loading" + ? startingProject?.root === selectedStudioRoot : siblingSessionPending } onNewSession={ planningWorkspace - ? agentMapEntry.openFreshPlanner + ? startProjectSessionFromMap : activeSession ? () => handleStartSiblingSession(activeSession) : null @@ -3163,64 +3094,46 @@ export const App = (): JSX.Element => { } /> ) : planningWorkspace ? ( - agentMapEntry.state.planner.status === "error" ? ( - - Retry session - - } - /> - ) : agentMapEntry.state.planner.status === "loading" ? ( - - ) : activePlannerSession?.planning ? ( - /* Agent Map planning is still an ordinary coding-agent - session. Keep the exact same raw CLI surface used for - every agent: trust/auth prompts, slash commands, tool - output, and provider chrome must remain visible rather - than being replaced by a transcript/composer facsimile. */ + activeProjectSession ? ( + /* A project session is an ordinary coding-agent session with + the Agent Map tools. Keep the exact same raw CLI surface + used for every agent. */
) : ( + /* Honest absence: the map renders from durable state on the + right and nothing was started (SAP-3143). Start goes + through the same createSession path as the + tab. */ - New planning session - + startProjectSessionFromMap ? ( + + ) : undefined } /> ) diff --git a/packages/harness/web/src/components/SessionBar.tsx b/packages/harness/web/src/components/SessionBar.tsx index 9fac5ece6..edff16336 100644 --- a/packages/harness/web/src/components/SessionBar.tsx +++ b/packages/harness/web/src/components/SessionBar.tsx @@ -20,9 +20,6 @@ function workspaceLabelOf(path: string): string { const EMPTY_BUSY_SESSION_IDS: ReadonlySet = new Set(); interface SessionBarProps { - /** Planner sessions keep normal tabs/rename/end but have no meaningful - * filesystem path or editor action in this workspace. */ - planning?: boolean; /** The main panel is showing the Overview/intro, not a session. */ overviewMode?: boolean; /** Set while an agent is open whose workspace has no live session. */ @@ -86,7 +83,6 @@ interface SessionBarProps { * its caret, while agent actions remain right-anchored on the same row. */ export function SessionBar({ - planning = false, overviewMode = false, openedAgentName = null, reviewTitle = null, @@ -237,11 +233,7 @@ export function SessionBar({ menuOpen={menuOpen} onToggleMenu={() => setMenuOpen((open) => !open)} menuTriggerRef={menuTriggerRef} - menuTooltip={ - planning - ? `${HARNESS_LABELS[activeSession.harness]} · Agent Map` - : `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` - } + menuTooltip={`${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}`} renaming={renaming} renameDraft={renameDraft} onRenameDraftChange={setRenameDraft} @@ -274,11 +266,7 @@ export function SessionBar({ data-testid="session-menu" aria-haspopup="menu" aria-expanded={menuOpen} - data-tooltip={ - planning - ? `${HARNESS_LABELS[activeSession.harness]} · Agent Map` - : `${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}` - } + data-tooltip={`${HARNESS_LABELS[activeSession.harness]} · ${workspaceLabelOf(activeSession.cwd)} · ${activeSession.cwd}`} onClick={() => setMenuOpen((open) => !open)} {...trackingAttrs({ object: "session" })} > @@ -332,22 +320,20 @@ export function SessionBar({
)} - {!planning && ( - - )} + - {!planning && ( - - )} + {activeSession.status !== "exited" && ( - )} - {actions} {onExpandRight && ( @@ -455,11 +420,6 @@ export function SessionBar({ {confirmingClose && activeSession && ( setConfirmingClose(false)} onConfirm={() => { setConfirmingClose(false); diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index b4f2efcf4..8ea3d179c 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -177,8 +177,6 @@ interface WorkflowsRailProps { onOpenProject: (root: string) => Promise; launchDir: string | null; listDir: (path?: string) => Promise; - /** Starts a coding-agent session and owns its failure feedback. */ - onStartProjectSession: (root: string, label: string) => Promise; /** Adapter registry fetch — the add dialog's picker and MCP setup block. */ listHarnesses: () => Promise; /** @@ -263,22 +261,21 @@ const SORT_LABELS: Record = { }; /** - * The project row's overflow menu. + * The project row's trailing actions. * - * The adjacent `+` has one stable meaning: start a coding-agent session at this - * project's root. This menu keeps the lower-frequency, explicitly named - * project actions. On legacy servers that includes scaffolding a Sapiom agent; - * on current plan-first projects, agent creation remains owned by Plan Agents. + * HOVER ACTIONS, NOT A MENU (design-eng D33: "a project row's verbs are hover + * actions on the header ... a per-row menu would be a new idiom"). The overflow + * this replaces wrapped a single destructive verb on every plan-first project, + * because those let their Agent Map own creation: a popover, a card and a 248px + * min-width spent on one X. The legacy create action, when a server still + * offers one, is the other hover action rather than a menu's first row. * - * Named items say it instead. Each carries the project's own label, so the - * subject is read rather than inferred, and the destructive one is last and - * marked. - * - * The trigger keeps its own open state and its own ref: `triggerRef` is what - * the remove confirmation returns focus to, and the menu item that opened it - * has unmounted by then. + * The X removes the project from the rail; it never touches a file. `onRemove` + * is handed the button so the confirmation returns focus to the control that + * opened it — the reason the menu needed a ref of its own, and the reason this + * still does. */ -function ProjectRowMenu({ +function ProjectRowActions({ label, create, onRemove, @@ -296,80 +293,41 @@ function ProjectRowMenu({ } | null; onRemove: (trigger: HTMLButtonElement | null) => void; }): JSX.Element { - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); + const removeRef = useRef(null); return ( <> + {create && ( + + )} + {/* REMOVE. An `X`, not a trash can: this closes a project and ends its + sessions, and never touches a file — a bin glyph would say the + opposite of the copy in the confirm. The subject the menu item spelled + out ("Remove acme-app from the rail") now rides the accessible name and + the tooltip, and the confirmation restates it, with the count of + sessions it will end, before anything happens. */} - setOpen(false)} - placement="down-end" - className="menu-flyer" - testid={`project-menu-card-${label}`} - > -
-
- {create && ( - - )} - {/* REMOVE. An `X`, not a trash can: this closes a project and ends - its sessions, and never touches a file — a bin glyph would say - the opposite of the copy in the confirm. */} - -
-
-
); } @@ -488,7 +446,6 @@ export function WorkflowsRail({ onOpenProject, launchDir, listDir, - onStartProjectSession, listHarnesses, onCreateAgent, onScaffoldInSession, @@ -1232,10 +1189,15 @@ export function WorkflowsRail({ const studioProject = studioProjects?.find( (candidate) => candidate.projectId === workspaceScope?.projectId, ); - // Current servers issue a durable Studio project for every scope, - // and that project's Agent Map owns creation. The absent case is a - // compatibility payload, not a second creation mode. Keep ownership - // independent of the selected axis so Group cannot restore a bypass. + // Current servers issue a durable Studio project for every scope. + // This no longer gates the row's `+`: creation was delegated to the + // pinned Agent Map, whose only working route was the planner session + // that SAP-3143 deletes — so on a current server the row and the + // empty-project row both went silent and a project with agents had + // no scoped way to grow another. The `+` is New agent again + // (IA.md 219, D34a). What this still owns is the EMPTY project's + // extra create row, which D36 removes in favour of its Agent Map + // row acting as the CTA. const mapOwnsCreation = studioProject != null; const planFirst = axis === "project" && mapOwnsCreation; const mapSelected = @@ -1382,37 +1344,16 @@ export function WorkflowsRail({ )} - {/* START A SESSION HERE. This is the frequent project-row - action and therefore stays one click away, immediately - before the overflow menu. Its accessible name supplies - the noun the glyph cannot: this starts a coding-agent - SESSION at the project root. It does not scaffold a - Sapiom agent or bypass Plan Agents. */} - {!pending && ( - - )} - {/* NAMED PROJECT ACTIONS. The destructive action stays in - this menu instead of masquerading as a peer of the - session shortcut. Legacy-only agent creation also - remains spelled out here rather than sharing the `+`. */} - { - // Focus returns to the ⋮, not to the menu item that - // opened the dialog: that item unmounts with the - // popover, and a `triggerRef` pointing at a detached - // node restores focus to . + // Focus returns to the X itself. The menu this + // replaced had to hand back its trigger instead: the + // item that opened the dialog unmounted with the + // popover, and a ref on a detached node restores + // focus to . removeTriggerRef.current = trigger; setRemoving({ root: project.root, diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 9d7625c32..787b6d766 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -7,6 +7,7 @@ import { parseAgentMapWorkspaceResponse, resolveStudioWorkspaceSelection, routeAcceptedProposalDelta, + studioProjectRoot, } from "./agent-map"; import { MockApi } from "./api"; @@ -276,3 +277,31 @@ function validResponseProject(id: string): StudioProjectSummary { updatedAt: timestamp, }; } + +describe("studioProjectRoot", () => { + const scope = (cwd: string) => ({ workspaceKey: cwd, cwd }); + + it("is the OUTERMOST bound scope, where mostSpecificStudioScope is the innermost", () => { + const scopes = [scope("/w/project/nested"), scope("/w/project")]; + expect(studioProjectRoot(scopes)).toBe("/w/project"); + }); + + it("is null when the project has no bound scope", () => { + expect(studioProjectRoot([])).toBeNull(); + }); + + it("breaks a same-depth tie deterministically, so two renders agree", () => { + expect(studioProjectRoot([scope("/w/beta"), scope("/w/alfa")])).toBe( + "/w/alfa", + ); + expect(studioProjectRoot([scope("/w/alfa"), scope("/w/beta")])).toBe( + "/w/alfa", + ); + }); + + it("ignores a trailing separator when comparing depth", () => { + expect(studioProjectRoot([scope("/w/project/nested"), scope("/w/proj/")])).toBe( + "/w/proj/", + ); + }); +}); diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index 41aa0f308..769e44b37 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -320,6 +320,29 @@ export function resolveStudioWorkspaceSelection( return { selection, repair: false }; } +/** + * A Studio project's ROOT: the outermost of its bound scopes. + * + * The counterpart to {@link mostSpecificStudioScope}, which answers "which + * scope contains this path". Sessions and the Start action belong to the + * project, so they key to its root; with nested bound scopes the two answers + * differ, and deriving the root separately at a call site is how the tab strip + * and the conversation subject end up pointing at different folders. + */ +export function studioProjectRoot( + scopes: readonly WorkspaceScopeSummary[], +): string | null { + return ( + scopes + .map((scope) => scope.cwd) + .sort( + (left, right) => + stripTrailingSep(left).length - stripTrailingSep(right).length || + left.localeCompare(right), + )[0] ?? null + ); +} + /** * The durable Studio project owning a path. Nested opened projects use the * nearest containing root, matching session scope rather than recent-dir order. diff --git a/packages/harness/web/src/lib/api.test.ts b/packages/harness/web/src/lib/api.test.ts index 7a4eae7f0..dec7d1555 100644 --- a/packages/harness/web/src/lib/api.test.ts +++ b/packages/harness/web/src/lib/api.test.ts @@ -493,69 +493,6 @@ describe("RealApi.getSystemGraph", () => { }); }); -describe("RealApi planner mutations", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("retains the authoritative metadata returned by send and greeting retry", async () => { - if (isMockMode()) return; - vi.stubGlobal("window", { - __HARNESS__: { token: "test-token" }, - location: { search: "" }, - }); - const accepted = { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner" as const, - }, - greeting: { status: "skipped" as const, reason: "user-proceeded" }, - queuedInputIds: ["input-1"], - }; - const retrying = { - ...accepted, - greeting: { - status: "generating" as const, - attemptId: "attempt-2", - }, - queuedInputIds: [], - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ metadata: accepted }), { status: 202 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ metadata: retrying }), { status: 202 }), - ); - vi.stubGlobal("fetch", fetchMock); - - const api = createApi(); - await expect( - api.sendPlannerMessage("project-1", "planner-1", { text: "hello" }), - ).resolves.toEqual({ metadata: accepted }); - await expect( - api.retryPlannerGreeting("project-1", "planner-1"), - ).resolves.toEqual({ metadata: retrying }); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "/api/projects/project-1/planner-sessions/planner-1/messages", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ text: "hello" }), - }), - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - "/api/projects/project-1/planner-sessions/planner-1/greeting/retry", - expect.objectContaining({ method: "POST", body: "{}" }), - ); - }); -}); - describe("progressiveLeasingRun", () => { const at = (elapsed: number) => progressiveLeasingRun("exec-mock-prod-1", elapsed); diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 207985f29..8fea4c1fc 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -48,10 +48,6 @@ import type { AcceptedProposalDelta, AgentMapWorkspaceResponse, MapOperation, - PlannerMessageRequest, - PlannerSessionMetadataResponse, - PlannerSessionRequest, - PlannerSessionResponse, PutStudioCurrentWorkspaceRequest, StudioCurrentWorkspaceResponse, StudioProjectId, @@ -379,23 +375,6 @@ export interface HarnessApi { projectId: StudioProjectId, selection: StudioWorkspaceSelection, ): Promise; - openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise; - /** Compatibility surface for coordinator-driven clients. The Studio renders - * the planner's raw CLI and does not project this protocol into a second - * transcript/composer UI. */ - sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise; - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ - retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise; /** Revisioned local dependency projection for one server-issued workspace key. */ getSystemGraph( workspaceKey: WorkspaceKey, @@ -690,37 +669,6 @@ class RealApi implements HarnessApi { return parseStudioCurrentWorkspaceResponse(value, projectId); } - openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions`, - { method: "POST", body: JSON.stringify(request) }, - ); - } - - async sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions/${encodeURIComponent(sessionId)}/messages`, - { method: "POST", body: JSON.stringify(request) }, - ); - } - - async retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions/${encodeURIComponent(sessionId)}/greeting/retry`, - { method: "POST", body: "{}" }, - ); - } - async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -2075,9 +2023,6 @@ export class MockApi implements HarnessApi { this.fresh || this.noLiveSessions ? [] : MOCK_SESSIONS.map((session) => ({ ...session })); - /** Live planner records are mutable mock state, unlike the fixed history - * fixtures. They exercise the same record-refetch path as the real server. */ - private plannerSessionRecords = new Map(); private workflowsStore: WorkflowInfo[] = this.fresh ? [] : [ @@ -2517,311 +2462,6 @@ export class MockApi implements HarnessApi { return { ...current, selection, repaired: !valid }; } - async openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - if (typeof window !== "undefined") { - const win = window as unknown as { - __HARNESS_TEST__?: Record; - }; - const previous = - (win.__HARNESS_TEST__?.openPlannerSessionCalls as - | unknown[] - | undefined) ?? []; - win.__HARNESS_TEST__ = { - ...(win.__HARNESS_TEST__ ?? {}), - openPlannerSessionCalls: [...previous, { projectId, request }], - }; - } - const failure = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockPlanner"); - if (failure === "error") { - throw new ApiError( - 503, - "Planner service is unavailable", - "Planner service is unavailable", - ); - } - if (failure === "unauthorized") { - throw new ApiError( - 403, - "Planner project is not available", - "Planner project is not available", - ); - } - const existing = this.sessions - .filter( - (session) => - session.status !== "exited" && - session.planning?.identity.projectId === projectId && - session.planning.identity.userId === "user_mock", - ) - .sort((left, right) => - right.lastActiveAt.localeCompare(left.lastActiveAt), - )[0]; - if (request.mode === "resume-or-create" && existing) { - return { session: existing, resolution: "live" }; - } - const root = [...this.studioProjectIds.entries()].find( - ([, id]) => id === projectId, - )?.[0]; - if (!root) { - throw new ApiError( - 404, - "Studio project not found", - "Studio project not found", - ); - } - const session = await this.createSession({ - cwd: root, - harness: request.harness ?? "claude-code", - ...(request.theme ? { theme: request.theme } : {}), - }); - const greetingFixture = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockGreeting"); - session.planning = { - identity: { - projectId, - sessionId: session.id, - userId: "user_mock", - role: "map-planner", - }, - greeting: - greetingFixture === "generating" - ? { status: "generating", attemptId: "attempt_mock" } - : greetingFixture === "failed" - ? { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - } - : { - status: "delivered", - messageId: "message_mock_greeting", - }, - queuedInputIds: [], - }; - const now = new Date().toISOString(); - this.plannerSessionRecords.set(session.id, { - harnessSessionId: session.id, - mergedSessionIds: [session.id], - agentSessionId: session.agentSessionId, - harness: session.harness, - cwd: session.cwd, - startedAt: now, - endedAt: null, - turns: - session.planning.greeting.status === "delivered" - ? [ - { - index: 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: - "I’m your project planning agent. We’ll plan the agents, responsibilities, data flow, resources, and connectors together. What kind of agent architecture do you want to build?", - model: "mock-planner", - usage: null, - completedAt: now, - incomplete: false, - }, - ] - : [], - turnCount: 0, - eventCount: session.planning.greeting.status === "delivered" ? 2 : 0, - reconstructed: true, - archivedAt: null, - limitations: [], - }); - return { session, resolution: "created" }; - } - - async sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise { - const session = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - if (session?.planning?.identity.projectId !== projectId) { - throw new ApiError( - 403, - "Forbidden planner session", - "Forbidden planner session", - ); - } - const inputId = `input_mock_${Date.now()}`; - session.planning = { - ...session.planning, - greeting: - session.planning.greeting.status === "delivered" || - session.planning.greeting.status === "skipped" - ? session.planning.greeting - : { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [...session.planning.queuedInputIds, inputId], - }; - await this.injectInput(sessionId, { text: request.text }); - const accepted = structuredClone(session.planning); - const project = this.studioProjects()?.find( - (candidate) => candidate.projectId === projectId, - ); - const goldenFixtureEnabled = - typeof window !== "undefined" && - new URLSearchParams(window.location.search).get("mockAgentMapGolden") === - "1"; - setTimeout( - () => { - const current = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const record = this.plannerSessionRecords.get(sessionId); - if (!current?.planning || !record) return; - const completedAt = new Date().toISOString(); - const turns = [ - ...record.turns, - { - index: record.turns.length + 1, - prompt: request.text, - promptAt: completedAt, - toolCalls: [], - assistantText: - "Let’s start by clarifying the outcome, the actors involved, and the information they need to exchange.", - model: "mock-planner", - usage: null, - completedAt, - incomplete: false, - }, - ]; - this.plannerSessionRecords.set(sessionId, { - ...record, - turns, - turnCount: record.turnCount + 1, - eventCount: record.eventCount + 2, - }); - current.planning = { - ...current.planning, - queuedInputIds: current.planning.queuedInputIds.filter( - (candidate) => candidate !== inputId, - ), - }; - void import("./events").then(({ publishMockBusMessage }) => { - if (goldenFixtureEnabled && !this.agentMapSnapshots.has(projectId)) { - if (!project) return; - const fixture = goldenAgentMapFixture( - project, - new Date().toISOString(), - accepted.identity.userId, - sessionId, - ); - this.agentMapSnapshots.set(projectId, fixture.snapshot); - publishMockBusMessage({ - type: "agent-map.proposal.changed", - delta: fixture.delta, - }); - } - publishMockBusMessage({ type: "session.status", session: current }); - publishMockBusMessage({ - type: "session.record.changed", - harnessSessionId: sessionId, - }); - }); - }, - goldenFixtureEnabled ? 0 : 250, - ); - return { metadata: accepted }; - } - - async retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - const session = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - if (session?.planning?.identity.projectId !== projectId) { - throw new ApiError( - 403, - "Forbidden planner session", - "Forbidden planner session", - ); - } - const retryFailure = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockGreetingRetry"); - if (retryFailure === "error") { - throw new ApiError( - 503, - "Greeting retry is temporarily unavailable", - "Greeting retry is temporarily unavailable", - ); - } - if ( - session.planning.greeting.status !== "failed" || - !session.planning.greeting.retryable || - session.planning.queuedInputIds.length > 0 - ) { - throw new ApiError( - 409, - "Greeting retry is not available", - "Greeting retry is not available", - ); - } - session.planning = { - ...session.planning, - greeting: { status: "generating", attemptId: "attempt_mock_retry" }, - }; - const retrying = structuredClone(session.planning); - setTimeout(() => { - const current = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const record = this.plannerSessionRecords.get(sessionId); - if (!current?.planning || !record) return; - const completedAt = new Date().toISOString(); - current.planning = { - ...current.planning, - greeting: { - status: "delivered", - messageId: "message_mock_greeting_retry", - }, - }; - this.plannerSessionRecords.set(sessionId, { - ...record, - turns: [ - ...record.turns, - { - index: record.turns.length + 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: - "I’m your project planning agent. What kind of agent architecture do you want to build?", - model: "mock-planner", - usage: null, - completedAt, - incomplete: false, - }, - ], - eventCount: record.eventCount + 2, - }); - void import("./events").then(({ publishMockBusMessage }) => { - publishMockBusMessage({ type: "session.status", session: current }); - publishMockBusMessage({ - type: "session.record.changed", - harnessSessionId: sessionId, - }); - }); - }, 250); - return { metadata: retrying }; - } - async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -3168,9 +2808,7 @@ export class MockApi implements HarnessApi { await delay(); // Null for an id with no fixture — the same "nothing recorded" answer the // real client returns for a 404, so the empty state is exercised too. - return ( - this.plannerSessionRecords.get(id) ?? MOCK_SESSION_RECORDS[id] ?? null - ); + return MOCK_SESSION_RECORDS[id] ?? null; } async resumeSession(id: string): Promise { diff --git a/packages/harness/web/src/lib/use-agent-map-entry.ts b/packages/harness/web/src/lib/use-agent-map-entry.ts index 7178c7a92..175724dc0 100644 --- a/packages/harness/web/src/lib/use-agent-map-entry.ts +++ b/packages/harness/web/src/lib/use-agent-map-entry.ts @@ -1,12 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentMapWorkspaceResponse, AcceptedProposalDelta, - PlannerSessionRequest, - PlannerSessionResponse, StudioProjectId, } from "@shared/agent-map"; -import type { HarnessKind, HarnessSession, UiTheme } from "@shared/types"; import { ApiError, errorMessage, type HarnessApi } from "./api"; import { track } from "./track"; @@ -22,39 +19,17 @@ export type AgentMapWorkspacePaneState = } | { status: "error"; message: string }; -export type AgentMapPlannerPaneState = - | { status: "idle" } - | { status: "loading" } - | { status: "ready"; value: PlannerSessionResponse } - | { status: "error"; message: string }; - export interface AgentMapEntryState { projectId: StudioProjectId | null; workspace: AgentMapWorkspacePaneState; - planner: AgentMapPlannerPaneState; - /** Missing/deleted/foreign projects are the only errors that replace both - * panes. Every ordinary read/launch failure stays local to its own pane. */ + /** Missing/deleted/foreign projects replace the whole map pane. Every + * ordinary read failure stays local to the pane. */ unavailable: string | null; } interface AgentMapEntryOptions { projectId: StudioProjectId | null; - /** The user's explicit live planner selection, when it already belongs to - * this project. It is more specific than project-level resume ordering. */ - selectedPlanner: HarnessSession | null; api: HarnessApi; - /** Read at launch time so a theme/provider change made while the workspace - * is open is honored by the next explicit fresh-session action. */ - harness: () => HarnessKind; - theme: () => UiTheme; - openPlannerSession: ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ) => Promise; - onPlannerReady: ( - response: PlannerSessionResponse, - mode: PlannerSessionRequest["mode"], - ) => void; subscribeProposalChanges: ( listener: (delta: AcceptedProposalDelta) => void, ) => () => void; @@ -64,7 +39,6 @@ interface AgentMapEntryOptions { const EMPTY_ENTRY: AgentMapEntryState = { projectId: null, workspace: { status: "idle" }, - planner: { status: "idle" }, unavailable: null, }; @@ -93,18 +67,6 @@ function failureDimensions( }; } -function selectedPlannerResponse( - projectId: StudioProjectId | null, - session: HarnessSession | null, -): PlannerSessionResponse | null { - return projectId !== null && - session?.status !== "exited" && - session?.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === projectId - ? { session, resolution: "live" } - : null; -} - /** The shared gate before an accepted delta may mutate state or telemetry. */ export function shouldCommitAcceptedDelta( currentProjectId: StudioProjectId | null, @@ -123,42 +85,28 @@ export function shouldCommitAcceptedDelta( } /** - * Opens the two halves of the first Agent Map experience concurrently. + * Reads the Agent Map for the selected project from durable state. * - * Workspace reads and planner launches have separate request generations and - * separate retry verbs. A late response from a project the user already left - * is ignored. The hook intentionally does not own session state: the returned - * planner session is handed to the central harness store through - * `openPlannerSession`, so tabs, status events, and planner metadata share one - * canonical `HarnessSession` projection. + * Opening a project starts nothing (SAP-3143): the map is a read-only view of + * what the store holds, and the project's sessions are ordinary sessions the + * user starts explicitly. A late response from a project the user already left + * is ignored. */ export function useAgentMapEntry({ projectId, - selectedPlanner, api, - harness, - theme, - openPlannerSession, - onPlannerReady, subscribeProposalChanges, subscribeReconnects, }: AgentMapEntryOptions): { state: AgentMapEntryState; retryWorkspace: () => void; - retryPlanner: () => void; retryAll: () => void; - openFreshPlanner: () => void; } { const [state, setState] = useState(EMPTY_ENTRY); const currentProjectRef = useRef(projectId); const startedProjectRef = useRef(null); const workspaceRequestRef = useRef(0); - const plannerRequestRef = useRef(0); const apiRef = useRef(api); - const harnessRef = useRef(harness); - const themeRef = useRef(theme); - const openPlannerRef = useRef(openPlannerSession); - const onPlannerReadyRef = useRef(onPlannerReady); const visibleProposalRef = useRef(new Map()); const visibleDeltaRef = useRef( new Map(), @@ -166,26 +114,13 @@ export function useAgentMapEntry({ currentProjectRef.current = projectId; apiRef.current = api; - harnessRef.current = harness; - themeRef.current = theme; - openPlannerRef.current = openPlannerSession; - onPlannerReadyRef.current = onPlannerReady; - - const selectedResponse = useMemo( - () => selectedPlannerResponse(projectId, selectedPlanner), - [projectId, selectedPlanner], - ); const loadWorkspace = useCallback((target: StudioProjectId): void => { const request = ++workspaceRequestRef.current; setState((current) => ({ ...(current.projectId === target ? current - : { - projectId: target, - planner: { status: "idle" } as AgentMapPlannerPaneState, - unavailable: null, - }), + : { projectId: target, unavailable: null }), projectId: target, workspace: { status: "loading" }, })); @@ -332,95 +267,25 @@ export function useAgentMapEntry({ }; }, [loadWorkspace, projectId, subscribeProposalChanges, subscribeReconnects]); - const loadPlanner = useCallback( - (target: StudioProjectId, mode: PlannerSessionRequest["mode"]): void => { - const request = ++plannerRequestRef.current; - setState((current) => ({ - ...(current.projectId === target - ? current - : { - projectId: target, - workspace: { status: "idle" } as AgentMapWorkspacePaneState, - unavailable: null, - }), - projectId: target, - planner: { status: "loading" }, - })); - void openPlannerRef - .current(target, { - mode, - harness: harnessRef.current(), - theme: themeRef.current(), - }) - .then( - (value) => { - if ( - currentProjectRef.current !== target || - plannerRequestRef.current !== request - ) - return; - onPlannerReadyRef.current(value, mode); - setState((current) => - current.projectId === target - ? { ...current, planner: { status: "ready", value } } - : current, - ); - }, - (error: unknown) => { - if ( - currentProjectRef.current !== target || - plannerRequestRef.current !== request - ) - return; - track("agent_map.workspace_load_failed", { - ...failureDimensions(target, error), - pane: "planner", - }); - const message = errorMessage( - error, - "The planning conversation could not be opened.", - ); - setState((current) => - current.projectId === target - ? { - ...current, - planner: { status: "error", message }, - unavailable: isWholeWorkspaceUnavailable(error) - ? message - : current.unavailable, - } - : current, - ); - }, - ); - }, - [], - ); - useEffect(() => { if (projectId === null) { startedProjectRef.current = null; workspaceRequestRef.current += 1; - plannerRequestRef.current += 1; setState(EMPTY_ENTRY); return; } - // The ref survives React StrictMode's setup/cleanup probe, preventing two - // planner launches and duplicate entry telemetry for one visible visit. + // The ref survives React StrictMode's setup/cleanup probe, preventing + // duplicate entry telemetry for one visible visit. if (startedProjectRef.current === projectId) return; startedProjectRef.current = projectId; setState({ projectId, workspace: { status: "loading" }, - planner: selectedResponse - ? { status: "ready", value: selectedResponse } - : { status: "loading" }, unavailable: null, }); track("agent_map.entered", { project_id: projectId }); loadWorkspace(projectId); - if (!selectedResponse) loadPlanner(projectId, "resume-or-create"); - }, [loadPlanner, loadWorkspace, projectId, selectedResponse]); + }, [loadWorkspace, projectId]); const retryWorkspace = useCallback((): void => { const target = currentProjectRef.current; @@ -429,27 +294,9 @@ export function useAgentMapEntry({ loadWorkspace(target); }, [loadWorkspace]); - const retryPlanner = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadPlanner(target, "resume-or-create"); - }, [loadPlanner]); - - const retryAll = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadWorkspace(target); - loadPlanner(target, "resume-or-create"); - }, [loadPlanner, loadWorkspace]); - - const openFreshPlanner = useCallback((): void => { - const target = currentProjectRef.current; - if (!target) return; - setState((current) => ({ ...current, unavailable: null })); - loadPlanner(target, "fresh"); - }, [loadPlanner]); + // The map is the only pane this hook owns now; "retry all" is kept as the + // verb the whole-workspace error state calls. + const retryAll = retryWorkspace; return { state: @@ -460,14 +307,9 @@ export function useAgentMapEntry({ : { projectId, workspace: { status: "loading" }, - planner: selectedResponse - ? { status: "ready", value: selectedResponse } - : { status: "loading" }, unavailable: null, }, retryWorkspace, - retryPlanner, retryAll, - openFreshPlanner, }; } diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 1c77e2e54..cdf31efad 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -24,12 +24,6 @@ import type { TemplateDetailView, TemplateListResponse, } from "@shared/types"; -import type { - PlannerSessionRequest, - PlannerSessionResponse, - StudioProjectId, -} from "@shared/agent-map"; - import { ApiError, boundWorkflowPathOf, @@ -184,12 +178,6 @@ export interface HarnessStateHook { /** A past session's reconstructed transcript (null when nothing was * recorded for it). Stable identity — safe as an effect dependency. */ sessionRecord: (id: string) => Promise; - /** Opens the trusted map-planner for a project and publishes the returned - * session into the same store that backs the normal session strip. */ - openPlannerSession: ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ) => Promise; resumeSession: (harnessSessionId: string) => Promise; /** * Portable continue: a fresh session in `cwd`, seeded with our own @@ -552,10 +540,6 @@ export function useHarnessState(): HarnessStateHook { }, [], ); - // An HTTP planner mutation and its session.status projection can cross on - // the network. The bus owns the newer full-session snapshot, so an older - // response must not roll its planning metadata back after that snapshot. - const sessionStatusRevisions = useRef>(new Map()); const [busySessionIds, setBusySessionIds] = useState>(new Set()); const [tasks, setTasks] = useState([]); const busyTimers = useRef>>( @@ -1204,45 +1188,6 @@ export function useHarnessState(): HarnessStateHook { return [...(workflowProjectionOrder.current() ?? workflowsRef.current)]; }, [workflowProjectionOrder]); - /** One session projection for REST mutations and bus updates alike. */ - const upsertSession = useCallback((next: HarnessSession): void => { - setState((prev) => { - if (!prev) return prev; - const sessions = prev.sessions.some((session) => session.id === next.id) - ? prev.sessions.map((session) => - session.id === next.id ? next : session, - ) - : [...prev.sessions, next]; - return { ...prev, sessions }; - }); - }, []); - - const openPlannerSession = useCallback( - async ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise => { - const response = await api.openPlannerSession(projectId, request); - // A launch can emit session.status before its HTTP response crosses the - // wire. Preserve that newer full-session projection when it is already - // present; still insert the response if no bus-backed row exists. - if (!sessionStatusRevisions.current.has(response.session.id)) { - upsertSession(response.session); - } else { - setState((prev) => { - if (!prev) return prev; - return prev.sessions.some( - (session) => session.id === response.session.id, - ) - ? prev - : { ...prev, sessions: [...prev.sessions, response.session] }; - }); - } - return response; - }, - [upsertSession], - ); - useEffect(() => { return subscribeEvents((message) => { // SessionRecord invalidations have a targeted listener below. Keeping @@ -1253,10 +1198,6 @@ export function useHarnessState(): HarnessStateHook { systemGraphAnnouncementsAfterMessage(current, message), ); if (message.type === "session.status") { - sessionStatusRevisions.current.set( - message.session.id, - (sessionStatusRevisions.current.get(message.session.id) ?? 0) + 1, - ); setState((prev) => { if (!prev) return prev; const exists = prev.sessions.some( @@ -1731,20 +1672,17 @@ export function useHarnessState(): HarnessStateHook { ); setState((prev) => (prev ? { ...prev, sessions: remaining } : prev)); if (activeSessionId === id) { + // Prefer another live session in the SAME folder. Closing a tab must + // not jump the conversation to an unrelated project: at map altitude + // the centre would then show that project's empty state beside this + // project's map. (This is the project-scoped rule the planner-specific + // fallback used to express for planner sessions only.) const closed = state?.sessions.find((session) => session.id === id); - const nextPlanner = - closed?.planning?.identity.role === "map-planner" - ? remaining.find( - (session) => - session.status !== "exited" && - session.planning?.identity.role === "map-planner" && - session.planning.identity.projectId === - closed.planning?.identity.projectId, - ) - : undefined; + const live = remaining.filter((session) => session.status !== "exited"); const nextRunning = - nextPlanner ?? - remaining.find((session) => session.status !== "exited"); + (closed + ? live.find((session) => session.cwd === closed.cwd) + : undefined) ?? live[0]; selectSession(nextRunning ? nextRunning.id : null); } }, @@ -2382,7 +2320,6 @@ export function useHarnessState(): HarnessStateHook { getTemplate, getWorkflowInputContract, sessionRecord, - openPlannerSession, resumeSession, rehydrateSession, resumeFromHistory, diff --git a/packages/harness/web/src/styles.css b/packages/harness/web/src/styles.css index 8ea6697bd..f9703cc3a 100644 --- a/packages/harness/web/src/styles.css +++ b/packages/harness/web/src/styles.css @@ -2048,13 +2048,13 @@ button.rail-footer-card:hover { /* A collapsed merged group keeps its caret button visible, matching the inline caret's "there's more here" cue on plain headers. - NOT the project row's ⋮. This selector tests only that `aria-expanded` is - PRESENT, and the row menu's trigger always carries it — so without the - exclusion every collapsed project row grew a standing ⋮, which is the - "loudest thing in the rail" the trailing actions are hover-revealed to - avoid. An open menu keeps its own trigger visible through the rule below. */ -.workspace-row.is-collapsed - .workspace-row-action[aria-expanded]:not(.project-row-menu-trigger) { + The selector tests only that `aria-expanded` is PRESENT, so it catches any + row action that can open something. It used to need an exclusion for the + project row's ⋮, whose trigger always carried the attribute and therefore + stood permanently visible on every collapsed row — "the loudest thing in the + rail", which hover-revealing the trailing actions exists to avoid. That menu + is gone; the exclusion went with it. */ +.workspace-row.is-collapsed .workspace-row-action[aria-expanded] { opacity: 1; } @@ -2136,24 +2136,16 @@ button.rail-footer-card:hover { color: var(--text); } -/* ---- Project row ⋮ menu ---------------------------------------------- */ -/* Wide enough for the longest item to state its subject on one line — the - whole point of the menu is that "Remove acme-app from the rail" is read, - not inferred from a glyph, and a wrapped or ellipsed label gives that back. */ -.project-row-menu { - min-width: 248px; -} - -/* The destructive item, marked on hover the way the session menu's is. A wash - (`color-mix`), never a full-strength surface. +/* ---- Project row remove ------------------------------------------------ */ +/* The destructive row action, marked on hover with a wash (`color-mix`), never + a full-strength surface — the same weight the menu item it replaced carried. - The `.session-dropdown-item` qualifier is load-bearing, not decoration: the - menu items wear that class for their anatomy, its own `:hover` rule ties - this one on specificity, and it is declared LATER in this file — so the - neutral hover won and the destructive item marked itself in text colour - alone. Measured, not guessed: the computed background came back - `rgba(17, 17, 20, 0.04)`. */ -.session-dropdown-item.project-row-menu-danger:hover { + The `.workspace-row-action` qualifier is load-bearing, not decoration: row + actions wear that class for their anatomy, its own `:hover` rule ties this + one on specificity, and it is declared EARLIER in this file, so the neutral + hover would otherwise win on source order and the destructive action would + mark itself in text colour alone. */ +.workspace-row-action.project-row-remove:hover { color: var(--red-text); background: color-mix(in srgb, var(--red) 12%, transparent); }