From 58fe8a9f8fb35f894cd5ea7142239f66d1e42a2b Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 18:25:58 +0000 Subject: [PATCH 1/5] fix(harness): auto-map agents in empty planners --- .changeset/early-maps-start.md | 5 ++ packages/harness/README.md | 18 +++-- .../harness/src/core/planner-greeting.test.ts | 70 ++++++++++++++++--- packages/harness/src/core/planner-greeting.ts | 45 ++++++++---- .../harness/src/core/planning-session.test.ts | 25 +++++-- packages/harness/src/core/planning-session.ts | 12 ++-- .../harness/src/profiles/agent-map-planner.ts | 2 +- .../src/server/agent-map-mcp-wiring.test.ts | 9 +-- packages/harness/src/server/agent-map.ts | 2 +- packages/harness/src/shared/types.ts | 5 -- packages/harness/web/src/lib/api.ts | 2 +- 11 files changed, 142 insertions(+), 53 deletions(-) create mode 100644 .changeset/early-maps-start.md diff --git a/.changeset/early-maps-start.md b/.changeset/early-maps-start.md new file mode 100644 index 000000000..a92d72d55 --- /dev/null +++ b/.changeset/early-maps-start.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Automatically inspect existing project agents and draft a reviewable Agent Map proposal when a fresh planner opens an unstarted map. diff --git a/packages/harness/README.md b/packages/harness/README.md index d4510d9bc..8ca43c99a 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -56,9 +56,9 @@ from what its other components do on their own (the app's product analytics, and `npx @sapiom/mcp@latest` fetching and running the local MCP server each session): Planner-session bootstrap makes no additional network request. Its focused -context, greeting coordination, FIFO, and lifecycle persistence stay inside the -local server. Existing outbound surfaces remain the system-prompt fetch below, -the coding agent's ordinary provider traffic, and opt-in telemetry. +context, automatic empty-map inspection turn, FIFO, and lifecycle persistence +stay inside the local server. Existing outbound surfaces remain the system-prompt +fetch below, the coding agent's ordinary provider traffic, and opt-in telemetry. - **System prompt, on every session start** — an unauthenticated `GET https://api.sapiom.ai/v1/harness/system-prompt`, so the Studio conventions @@ -93,9 +93,17 @@ is project-scoped: live/resumable planner or creates one. Use `{ "mode": "fresh" }` to always create a new planner. - `POST /api/projects/:projectId/planner-sessions/:sessionId/messages` durably - accepts planner input and releases it FIFO after greeting resolution. + accepts planner input and releases it FIFO after startup-turn resolution. - `POST /api/projects/:projectId/planner-sessions/:sessionId/greeting/retry` - retries an eligible failed automatic greeting. + retries an eligible failed automatic startup turn. + +When a newly created planner sees no confirmed revision, active proposal, or +project build plan, it dispatches one server-authored startup turn after CLI +readiness. The planner reads the authoritative map, inspects the project +read-only for existing agents and evidence-backed relationships, validates the +result, and creates a proposal for the user to review. It never confirms or +implements that proposal automatically. Live, resumed, and rehydrated sessions +preserve their prior startup state instead of replaying the turn. Planner metadata is part of the session registry. Its input FIFO and greeting attempt state live at diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts index dd1b46c9e..1009bd7cd 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/planner-greeting.test.ts @@ -581,6 +581,43 @@ describe("PlannerGreetingCoordinator", () => { expect(session.planning?.queuedInputIds).toEqual([]); }); + it("uses a short readiness deadline without cutting off a longer startup mapping turn", async () => { + vi.useFakeTimers(); + session.ready = false; + const coordinator = new PlannerGreetingCoordinator({ + root, + sessionManager: manager, + readinessTimeoutMs: 100, + deliveryTimeoutMs: 1_000, + }); + await coordinator.register(session, { emptyProject: true, mode: "created" }); + 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: "session_not_ready", + }); + + session = plannerSession("session-2"); + await coordinator.register(session, { emptyProject: true, mode: "created" }); + expect(session.planning?.greeting.status).toBe("generating"); + expect(submitted).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(101); + expect(session.planning?.greeting.status).toBe("generating"); + + await vi.advanceTimersByTimeAsync(900); + await (coordinator as unknown as { writes: Map> }) + .writes.get(session.id); + expect(session.planning?.greeting).toEqual({ + status: "failed", + retryable: true, + errorCode: "delivery_timeout", + }); + }); + it("contains timer persistence rejection with only a bounded local classification", async () => { vi.useFakeTimers(); session.ready = false; @@ -940,20 +977,33 @@ describe("PlannerGreetingCoordinator", () => { }); describe("plannerGreetingPrompt", () => { - it("keeps the automatic greeting scoped to collaborative planning and one question", () => { + it("asks an empty project to inspect existing agents and draft a safe proposal", () => { const empty = plannerGreetingPrompt(true); + expect(empty).toContain("Agent Studio startup task"); + expect(empty).toContain("agent_map_read"); + expect(empty).toContain("inspect the current project read-only"); + expect(empty).toContain("explicit code or configuration evidence"); + expect(empty).toContain("agent_map_validate"); + expect(empty).toContain("agent_map_propose"); + expect(empty).toContain("Never confirm"); + expect(empty).toContain("do not create placeholder nodes"); + }); + + it("keeps the legacy existing-plan greeting conversational", () => { 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("project planning agent"); + expect(existing).toContain( + "agents, responsibilities, data flow, resources, and connectors", ); + expect(existing).toContain("exactly one open-ended question"); + expect(existing).toContain("Do not propose an architecture"); + expect(existing).toContain("invoke tools"); expect(existing).toContain("current plan exists"); + expect(existing).not.toContain("agent_map_propose"); + }); + + it("keeps attempt correlation out of the planner response", () => { + const empty = plannerGreetingPrompt(true); const attempted = plannerGreetingPrompt(true, "attempt-private-1"); expect(attempted).toContain("Internal attempt ID: attempt-private-1"); expect(attempted).toContain("Never mention this ID"); diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts index facd4e80c..8c488fb60 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/planner-greeting.ts @@ -70,7 +70,9 @@ export interface PlannerGreetingCoordinatorOptions { sessionManager: SessionManager; now?: () => string; generateId?: () => string; - /** Applies both while waiting for readiness and while awaiting a model turn. */ + /** Maximum wait for the CLI to become ready for its automatic startup turn. */ + readinessTimeoutMs?: number; + /** Maximum wait for the automatic model turn once it reaches the CLI. */ deliveryTimeoutMs?: number; /** Test seam for classifying queue-store failures without exposing raw errors. */ writeState?: (file: string, state: unknown) => Promise; @@ -237,19 +239,32 @@ 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."; + const instructions = emptyProject + ? [ + "This is an Agent Studio startup task for the project planning agent.", + "First call agent_map_read so the persisted Agent Map, not prior assistant prose, is authoritative.", + "If a proposal, confirmed revision, or project build plan now exists, do not modify it; briefly summarize what is present and ask the user what they want to review or change.", + "If the Agent Map is still empty, inspect the current project read-only for existing agent definitions, subagents, registries, calls, contracts, inputs, outputs, resources, connectors, and artifacts. You may browse files and run read-only searches, but do not edit source code or run implementation work.", + "Draft one node for each actual top-level agent you find, plus subagents, resources, connectors, and artifacts only where the project provides concrete evidence for them.", + "Add relationships only when explicit code or configuration evidence supports the direction and semantics; never guess from names, proximity, or likely architecture.", + "Call agent_map_validate before agent_map_propose, then create one reviewable proposal containing the evidence-backed topology. If proposing reports a version conflict, call agent_map_read again and do not overwrite newer work.", + "Never confirm, launch, deploy, or implement the proposal automatically.", + "After proposing, briefly summarize what you mapped, identify uncertainties or omitted relationships, and ask the user to review or correct it.", + "If the project contains no existing agents, do not create placeholder nodes; explain that nothing was found and ask one open-ended question about the outcome the user wants to build.", + ] + : [ + "This is an Agent Studio startup turn for the project planning agent.", + "Respond with one brief greeting.", + "Explain that you and the user will plan the agents, responsibilities, data flow, resources, and connectors together.", + "Briefly acknowledge that a current plan exists, then ask exactly one open-ended question about what the user wants to review, extend, or change.", + "Do not propose an architecture, create nodes or relationships, invoke tools, or ask a second question before the user replies.", + ]; 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.", + ...instructions, ...(attemptId ? [`Internal attempt ID: ${attemptId}. Never mention this ID in your response.`] : []), - ].join(" "); + ].join("\n"); } const PLANNER_SESSION_SOURCES = new Set([ @@ -321,6 +336,7 @@ export class PlannerGreetingCoordinator { private readonly root: string; private readonly now: () => string; private readonly generateId: () => string; + private readonly readinessTimeoutMs: number; private readonly deliveryTimeoutMs: number; private readonly states = new Map(); private readonly writes = new Map>(); @@ -344,7 +360,12 @@ export class PlannerGreetingCoordinator { this.root = path.resolve(options.root); this.now = options.now ?? (() => new Date().toISOString()); this.generateId = options.generateId ?? randomUUID; - this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 45_000; + // Preserve the old single-timeout behavior for callers that explicitly + // configured deliveryTimeoutMs while allowing repository inspection much + // longer than the CLI readiness handshake in production. + this.readinessTimeoutMs = + options.readinessTimeoutMs ?? options.deliveryTimeoutMs ?? 45_000; + this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 300_000; } private sessionDirectory(sessionId: string): string { @@ -639,7 +660,7 @@ export class PlannerGreetingCoordinator { "[harness] planner greeting timeout transition failed: persistence_failed", ); }); - }, this.deliveryTimeoutMs); + }, key === "pending" ? this.readinessTimeoutMs : this.deliveryTimeoutMs); handle.unref?.(); this.timers.set(sessionId, { key, handle }); } diff --git a/packages/harness/src/core/planning-session.test.ts b/packages/harness/src/core/planning-session.test.ts index 6976fcbad..fb101193b 100644 --- a/packages/harness/src/core/planning-session.test.ts +++ b/packages/harness/src/core/planning-session.test.ts @@ -185,6 +185,9 @@ describe("planner session context and identity", () => { expect(context).toContain(project.rootBindings[0]!.id); expect(context).toContain('"role":"map-planner"'); expect(context).toContain('"empty":true'); + expect(context).toContain( + "may receive a server-authored Agent Studio startup turn", + ); expect(context).toContain("In your first response, briefly explain"); expect(context).not.toContain("/Users/private"); expect(context).not.toContain("private-workspace-key"); @@ -273,16 +276,16 @@ describe("PlanningSessionService", () => { userId: "user-1", role: "map-planner", }, - greeting: { status: "skipped", reason: "user-proceeded" }, + greeting: { status: "pending" }, 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", + "may receive a server-authored Agent Studio startup turn", ), expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", + "may receive a server-authored Agent Studio startup turn", ), ]); expect(contexts.join("\n")).not.toContain( @@ -307,14 +310,21 @@ describe("PlanningSessionService", () => { ); }); - it("does not replay first-time onboarding for an already-planned project", async () => { + it("does not start automatic mapping for an already-planned project", async () => { const { service, contexts } = fixture([], project, { ...workspace, confirmedRevisionId: "revision-1", }); - await service.open(projectId, { mode: "fresh" }); + const result = await service.open(projectId, { mode: "fresh" }); + expect(result.session.planning?.greeting).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + expect(contexts[0]).not.toContain( + "may receive a server-authored Agent Studio startup turn", + ); expect(contexts[0]).not.toContain( "In your first response, briefly explain", ); @@ -328,7 +338,10 @@ describe("PlanningSessionService", () => { ]); expect(create).toHaveBeenCalledTimes(1); - expect(first).toMatchObject({ resolution: "created" }); + expect(first).toMatchObject({ + resolution: "created", + session: { planning: { greeting: { status: "pending" } } }, + }); expect(second).toMatchObject({ resolution: "live", session: { id: first.session.id }, diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts index 8d6e3f87b..bb9eb936e 100644 --- a/packages/harness/src/core/planning-session.ts +++ b/packages/harness/src/core/planning-session.ts @@ -203,7 +203,7 @@ export function buildFocusedPlannerContext(input: { }; 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.`, + `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 planner transcript is user-visible.${emptyProject ? " A newly created planner for an empty project may receive a server-authored Agent Studio startup turn before any human message; follow it as an explicit request to inspect the project and draft a reviewable initial map." : ""}${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 the startup task." : ""} Outside that startup turn, do not propose architecture or invoke mutation tools before the user asks you to.`, JSON.stringify(context), "", ].join("\n"); @@ -331,6 +331,8 @@ export class PlanningSessionService { workspace.confirmedRevisionId === null && workspace.activeProposalId === null && workspace.projectBuildPlanId === null; + const initialGreeting: PlannerGreetingState = + mode === "created" && emptyProject ? { status: "pending" } : greeting; const harness = request.harness ?? this.options.defaultHarness; const cwd = launchRoot(project); const details = await this.focusedDetails(project, workspace); @@ -343,7 +345,7 @@ export class PlanningSessionService { }, { planning: (sessionId) => - planningFor(project.projectId, sessionId, principal, greeting), + planningFor(project.projectId, sessionId, principal, initialGreeting), promptAppendix: (sessionId) => buildFocusedPlannerContext({ project, @@ -456,10 +458,8 @@ export class PlanningSessionService { 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. + // create() promotes this to the durable automatic startup turn only + // while the project still has no map, proposal, or build plan. { status: "skipped", reason: "user-proceeded" }, undefined, "created", diff --git a/packages/harness/src/profiles/agent-map-planner.ts b/packages/harness/src/profiles/agent-map-planner.ts index 61ddea817..2fe19ec9c 100644 --- a/packages/harness/src/profiles/agent-map-planner.ts +++ b/packages/harness/src/profiles/agent-map-planner.ts @@ -24,5 +24,5 @@ application source code, run implementation tasks, or deploy software. */ 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.", + "If this project does not have an Agent Map yet, Agent Studio will automatically inspect it for existing agents and draft an evidence-backed proposal for you to review. It will not confirm or implement the proposal automatically. If a map already exists, use this session to review, refine, or extend it.", ].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..781c6728e 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -202,10 +202,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { expect(created.session.agentMapIdentity).toEqual( created.session.planning.identity, ); - expect(created.session.planning.greeting).toEqual({ - status: "skipped", - reason: "user-proceeded", - }); + expect(created.session.planning.greeting).toEqual({ status: "pending" }); const launchOpts = launches[0]!; const metadata = launchOpts.agentMapMcp; @@ -223,7 +220,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { "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", + "may receive a server-authored Agent Studio startup turn", ); expect(systemPrompt).not.toContain("In your first response, briefly explain"); expect(systemPrompt).not.toContain(codingPrompt); @@ -235,7 +232,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { 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.", + "If this project does not have an Agent Map yet, Agent Studio will automatically inspect it for existing agents and draft an evidence-backed proposal for you to review. It will not confirm or implement the proposal automatically. If a map already exists, use this session to review, refine, or extend it.", ].join("\n"), ); const plannerEmitter = await fs.readFile( diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index cf83faa24..3fe113f7a 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -411,7 +411,7 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { }, ); - /** @deprecated Compatibility-only for sessions created before synthetic greeting removal. */ + /** Retries an eligible failed automatic planner startup turn. */ router.post( "/projects/:projectId/planner-sessions/:sessionId/greeting/retry", async (req, res, next) => { diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 596493871..158615372 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -832,15 +832,10 @@ export type AnalyticsEventType = | "planner_session.created" | "planner_session.resumed" | "planner_session.input_delivery_uncertain" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ | "planner_greeting.attempted" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ | "planner_greeting.delivered" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ | "planner_greeting.failed" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ | "planner_greeting.skipped" - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ | "planner_greeting.retried"; /** diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index c2e5f052e..ac9dec58e 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -391,7 +391,7 @@ export interface HarnessApi { sessionId: string, request: PlannerMessageRequest, ): Promise; - /** @deprecated Compatibility-only; new planner sessions do not inject synthetic greetings. */ + /** Retries an eligible failed automatic planner startup turn. */ retryPlannerGreeting( projectId: StudioProjectId, sessionId: string, From a9bd8de0fae9b61b7ed4020b356596826c2feb68 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 18:44:12 +0000 Subject: [PATCH 2/5] fix(harness): harden automatic planner startup --- .changeset/early-maps-start.md | 4 +- packages/harness/README.md | 4 +- .../harness/src/core/planner-greeting.test.ts | 76 +++++++++---------- packages/harness/src/core/planner-greeting.ts | 58 +++++++------- .../harness/src/core/planning-session.test.ts | 7 ++ packages/harness/src/core/planning-session.ts | 15 +++- .../harness/src/profiles/agent-map-planner.ts | 2 +- .../src/server/agent-map-mcp-wiring.test.ts | 2 +- 8 files changed, 94 insertions(+), 74 deletions(-) diff --git a/.changeset/early-maps-start.md b/.changeset/early-maps-start.md index a92d72d55..dccbe478e 100644 --- a/.changeset/early-maps-start.md +++ b/.changeset/early-maps-start.md @@ -1,5 +1,5 @@ --- -"@sapiom/harness": patch +"@sapiom/harness": minor --- -Automatically inspect existing project agents and draft a reviewable Agent Map proposal when a fresh planner opens an unstarted map. +Automatically run the configured planning agent to inspect existing project agents and draft a reviewable Agent Map proposal when a fresh planner opens an unstarted map. The automatic turn may consume provider credits and remains preemptible by user input. diff --git a/packages/harness/README.md b/packages/harness/README.md index 8ca43c99a..9bacabeb4 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -103,7 +103,9 @@ readiness. The planner reads the authoritative map, inspects the project read-only for existing agents and evidence-backed relationships, validates the result, and creates a proposal for the user to review. It never confirms or implements that proposal automatically. Live, resumed, and rehydrated sessions -preserve their prior startup state instead of replaying the turn. +preserve their prior startup state instead of replaying the turn. The automatic +turn uses the configured planning provider and may consume provider credits; a +real user message takes priority and skips or preempts unfinished startup work. Planner metadata is part of the session registry. Its input FIFO and greeting attempt state live at diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts index 1009bd7cd..c160be58e 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/planner-greeting.test.ts @@ -85,7 +85,7 @@ describe("PlannerGreetingCoordinator", () => { await fs.rm(root, { recursive: true, force: true }); }); - it("persists one ready-gated greeting, then releases accepted input FIFO", async () => { + it("lets accepted user input preempt an in-flight startup turn", async () => { const coordinator = new PlannerGreetingCoordinator({ root, sessionManager: manager, @@ -98,15 +98,21 @@ describe("PlannerGreetingCoordinator", () => { await coordinator.enqueue(session.id, "first user message"); await coordinator.enqueue(session.id, "second user message"); - expect(submitted).toEqual([greeting]); + expect(submitted).toEqual([ + greeting, + "first user message", + "second user message", + ]); + expect(session.planning?.greeting).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); const localPrompt = coordinator.decorateLocalEvent( event(session.id, "prompt.submitted", { prompt: greeting }), ); - expect(localPrompt.payload).toMatchObject({ - prompt: greeting, - plannerOrigin: "infrastructure", - }); + expect(localPrompt.payload).toMatchObject({ prompt: greeting }); + expect(localPrompt.payload).not.toHaveProperty("plannerOrigin"); expect(coordinator.redactForTelemetry(localPrompt).payload).not.toHaveProperty( "prompt", ); @@ -114,13 +120,8 @@ describe("PlannerGreetingCoordinator", () => { 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" }, + greeting: { status: "skipped", reason: "user-proceeded" }, queuedInputIds: [], }); const durable = JSON.parse( @@ -556,7 +557,7 @@ describe("PlannerGreetingCoordinator", () => { ).toEqual({ schemaVersion: 1, inputIds: [] }); }); - it("bounds pending readiness, then drains its durable FIFO when readiness arrives", async () => { + it("lets user input preempt a pending startup turn, then drains at readiness", async () => { vi.useFakeTimers(); session.ready = false; const coordinator = new PlannerGreetingCoordinator({ @@ -566,6 +567,10 @@ describe("PlannerGreetingCoordinator", () => { }); await coordinator.register(session, { emptyProject: true, mode: "created" }); await coordinator.enqueue(session.id, "queued while booting"); + expect(session.planning?.greeting).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); await vi.advanceTimersByTimeAsync(101); await (coordinator as unknown as { writes: Map> }) .writes.get(session.id); @@ -977,36 +982,31 @@ describe("PlannerGreetingCoordinator", () => { }); describe("plannerGreetingPrompt", () => { - it("asks an empty project to inspect existing agents and draft a safe proposal", () => { - const empty = plannerGreetingPrompt(true); - expect(empty).toContain("Agent Studio startup task"); - expect(empty).toContain("agent_map_read"); - expect(empty).toContain("inspect the current project read-only"); - expect(empty).toContain("explicit code or configuration evidence"); - expect(empty).toContain("agent_map_validate"); - expect(empty).toContain("agent_map_propose"); - expect(empty).toContain("Never confirm"); - expect(empty).toContain("do not create placeholder nodes"); + it("keeps the automatic request single-line, human-readable, and review-only", () => { + const empty = plannerGreetingPrompt(true, 1); + expect(empty).toContain("Agent Studio automatic request"); + expect(empty).toContain("Inspect this project for existing agents"); + expect(empty).toContain("unconfirmed Agent Map proposal"); + expect(empty).not.toContain("\n"); + expect(empty).not.toContain("agent_map_read"); + expect(empty).not.toContain("Internal attempt ID"); + expect(empty).not.toContain("retry"); }); it("keeps the legacy existing-plan greeting conversational", () => { - const existing = plannerGreetingPrompt(false); - expect(existing).toContain("project planning agent"); - expect(existing).toContain( - "agents, responsibilities, data flow, resources, and connectors", - ); - expect(existing).toContain("exactly one open-ended question"); - expect(existing).toContain("Do not propose an architecture"); - expect(existing).toContain("invoke tools"); - expect(existing).toContain("current plan exists"); + const existing = plannerGreetingPrompt(false, 1); + expect(existing).toContain("current Agent Map"); + expect(existing).toContain("review, extend, or change"); + expect(existing).not.toContain("\n"); expect(existing).not.toContain("agent_map_propose"); }); - it("keeps attempt correlation out of the planner response", () => { - const empty = plannerGreetingPrompt(true); - 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"); + it("uses a human-readable ordinal to keep retry prompts distinct", () => { + const initial = plannerGreetingPrompt(true, 1); + const retry = plannerGreetingPrompt(true, 2); + expect(retry).not.toBe(initial); + expect(retry).toContain("Automatic retry 1 of 2"); + expect(plannerGreetingPrompt(true, 3)).toContain("Automatic retry 2 of 2"); + expect(retry).not.toContain("attempt-private"); }); }); diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts index 8c488fb60..e9d6417a5 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/planner-greeting.ts @@ -237,34 +237,17 @@ function adoptRehydratedState( export function plannerGreetingPrompt( emptyProject: boolean, - attemptId?: string, + attemptNumber = 1, ): string { - const instructions = emptyProject - ? [ - "This is an Agent Studio startup task for the project planning agent.", - "First call agent_map_read so the persisted Agent Map, not prior assistant prose, is authoritative.", - "If a proposal, confirmed revision, or project build plan now exists, do not modify it; briefly summarize what is present and ask the user what they want to review or change.", - "If the Agent Map is still empty, inspect the current project read-only for existing agent definitions, subagents, registries, calls, contracts, inputs, outputs, resources, connectors, and artifacts. You may browse files and run read-only searches, but do not edit source code or run implementation work.", - "Draft one node for each actual top-level agent you find, plus subagents, resources, connectors, and artifacts only where the project provides concrete evidence for them.", - "Add relationships only when explicit code or configuration evidence supports the direction and semantics; never guess from names, proximity, or likely architecture.", - "Call agent_map_validate before agent_map_propose, then create one reviewable proposal containing the evidence-backed topology. If proposing reports a version conflict, call agent_map_read again and do not overwrite newer work.", - "Never confirm, launch, deploy, or implement the proposal automatically.", - "After proposing, briefly summarize what you mapped, identify uncertainties or omitted relationships, and ask the user to review or correct it.", - "If the project contains no existing agents, do not create placeholder nodes; explain that nothing was found and ask one open-ended question about the outcome the user wants to build.", - ] - : [ - "This is an Agent Studio startup turn for the project planning agent.", - "Respond with one brief greeting.", - "Explain that you and the user will plan the agents, responsibilities, data flow, resources, and connectors together.", - "Briefly acknowledge that a current plan exists, then ask exactly one open-ended question about what the user wants to review, extend, or change.", - "Do not propose an architecture, create nodes or relationships, invoke tools, or ask a second question before the user replies.", - ]; - return [ - ...instructions, - ...(attemptId - ? [`Internal attempt ID: ${attemptId}. Never mention this ID in your response.`] - : []), - ].join("\n"); + const ordinal = Number.isSafeInteger(attemptNumber) + ? Math.min(Math.max(attemptNumber, 1), MAX_RETRIES + 1) + : 1; + const request = emptyProject + ? "Agent Studio automatic request: Inspect this project for existing agents and evidence-backed dependencies, then draft an unconfirmed Agent Map proposal for review." + : "Agent Studio automatic request: Briefly introduce the current Agent Map, then ask what the user wants to review, extend, or change."; + return ordinal === 1 + ? request + : `${request} (Automatic retry ${ordinal - 1} of ${MAX_RETRIES}.)`; } const PLANNER_SESSION_SOURCES = new Set([ @@ -1108,7 +1091,10 @@ export class PlannerGreetingCoordinator { attemptId, queueDepth: state.inputs.length, }); - const prompt = plannerGreetingPrompt(state.emptyProject, attemptId); + const prompt = plannerGreetingPrompt( + state.emptyProject, + state.retryCount + 1, + ); if (!(await this.canDispatch(session))) { await this.setFailure(state, attemptId, "session_exited", false); if (retry) throw new PlannerDispatchForbiddenError(); @@ -1247,17 +1233,29 @@ export class PlannerGreetingCoordinator { }; state.inputs.push(input); state.metadata.queuedInputIds.push(input.id); - if (state.metadata.greeting.status === "failed") { + const greeting = state.metadata.greeting; + const skipStartup = + greeting.status === "pending" || + greeting.status === "generating" || + greeting.status === "failed"; + const attemptId = + greeting.status === "generating" ? greeting.attemptId : undefined; + if (skipStartup) { state.metadata.greeting = { status: "skipped", reason: "user-proceeded" }; + } + await this.persist(sessionId, state); + if (skipStartup) { + this.clearTimer(sessionId); + 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.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 diff --git a/packages/harness/src/core/planning-session.test.ts b/packages/harness/src/core/planning-session.test.ts index fb101193b..2134240b9 100644 --- a/packages/harness/src/core/planning-session.test.ts +++ b/packages/harness/src/core/planning-session.test.ts @@ -188,6 +188,10 @@ describe("planner session context and identity", () => { expect(context).toContain( "may receive a server-authored Agent Studio startup turn", ); + expect(context).toContain( + "explicit code or configuration evidence supports them", + ); + expect(context).toContain("Never confirm, launch, deploy, or implement"); expect(context).toContain("In your first response, briefly explain"); expect(context).not.toContain("/Users/private"); expect(context).not.toContain("private-workspace-key"); @@ -305,6 +309,9 @@ describe("PlanningSessionService", () => { expect(sessionStartMessages).toEqual([ AGENT_MAP_PLANNER_SESSION_START_MESSAGE, ]); + expect(AGENT_MAP_PLANNER_SESSION_START_MESSAGE).toContain( + "Use this session to review, refine, or extend the Agent Map", + ); expect(contexts[0]).not.toContain( "In your first response, briefly explain", ); diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts index bb9eb936e..c248a8d46 100644 --- a/packages/harness/src/core/planning-session.ts +++ b/packages/harness/src/core/planning-session.ts @@ -133,6 +133,19 @@ function planningFor( }; } +const EMPTY_PROJECT_STARTUP_POLICY = [ + "A newly created planner for an empty project may receive a server-authored Agent Studio startup turn before any human message; treat that visible request as an explicit instruction to inspect the project and draft a reviewable initial map.", + "When it arrives, first call agent_map_read so persisted state remains authoritative.", + "If a proposal, confirmed revision, or project build plan now exists, do not modify it; summarize what is present and ask what the user wants to review or change.", + "If the map is still empty, inspect the project read-only for existing agent definitions, subagents, registries, calls, contracts, inputs, outputs, resources, connectors, and artifacts; do not edit source code or run implementation work.", + "Create nodes only for actual agents and supporting elements where explicit code or configuration evidence supports them.", + "Add relationships only when explicit evidence supports their direction and semantics; never guess from names, proximity, or likely architecture.", + "Call agent_map_validate before agent_map_propose and create one unconfirmed proposal. On a version conflict, call agent_map_read again and do not overwrite newer work.", + "Never confirm, launch, deploy, or implement the proposal automatically.", + "Summarize what was mapped, identify uncertainties or omitted relationships, and ask the user to review or correct it.", + "If no existing agents are found, create no placeholder nodes; explain that result and ask one open-ended question about the outcome the user wants to build.", +].join(" "); + export function buildFocusedPlannerContext(input: { project: StudioProjectIdentity; workspace: AgentMapWorkspaceState; @@ -203,7 +216,7 @@ export function buildFocusedPlannerContext(input: { }; 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 planner transcript is user-visible.${emptyProject ? " A newly created planner for an empty project may receive a server-authored Agent Studio startup turn before any human message; follow it as an explicit request to inspect the project and draft a reviewable initial map." : ""}${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 the startup task." : ""} Outside that startup turn, do not propose architecture or invoke mutation tools before the user asks you to.`, + `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 planner transcript is user-visible.${emptyProject ? ` ${EMPTY_PROJECT_STARTUP_POLICY}` : ""}${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 the startup task." : ""} Outside that startup turn, do not propose architecture or invoke mutation tools before the user asks you to.`, JSON.stringify(context), "", ].join("\n"); diff --git a/packages/harness/src/profiles/agent-map-planner.ts b/packages/harness/src/profiles/agent-map-planner.ts index 2fe19ec9c..cc015e845 100644 --- a/packages/harness/src/profiles/agent-map-planner.ts +++ b/packages/harness/src/profiles/agent-map-planner.ts @@ -24,5 +24,5 @@ application source code, run implementation tasks, or deploy software. */ export const AGENT_MAP_PLANNER_SESSION_START_MESSAGE = [ "Agent Map planning session", - "If this project does not have an Agent Map yet, Agent Studio will automatically inspect it for existing agents and draft an evidence-backed proposal for you to review. It will not confirm or implement the proposal automatically. If a map already exists, use this session to review, refine, or extend it.", + "Use this session to review, refine, or extend the Agent Map. If no map has been started, Agent Studio will automatically inspect the project for existing agents and draft an evidence-backed, unconfirmed proposal. The automatic inspection uses your configured planning agent; it does not edit code or begin implementation.", ].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 781c6728e..1039c61f1 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -232,7 +232,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { expect(AGENT_MAP_PLANNER_SESSION_START_MESSAGE).toBe( [ "Agent Map planning session", - "If this project does not have an Agent Map yet, Agent Studio will automatically inspect it for existing agents and draft an evidence-backed proposal for you to review. It will not confirm or implement the proposal automatically. If a map already exists, use this session to review, refine, or extend it.", + "Use this session to review, refine, or extend the Agent Map. If no map has been started, Agent Studio will automatically inspect the project for existing agents and draft an evidence-backed, unconfirmed proposal. The automatic inspection uses your configured planning agent; it does not edit code or begin implementation.", ].join("\n"), ); const plannerEmitter = await fs.readFile( From a551bc50ac9ac4aa5432d159035e8de80cdd7352 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 18:52:00 +0000 Subject: [PATCH 3/5] fix(harness): preserve preempted prompt origin --- .../harness/src/core/planner-greeting.test.ts | 16 +++++++++++----- packages/harness/src/core/planner-greeting.ts | 10 +++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts index c160be58e..fe2b568c9 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/planner-greeting.test.ts @@ -111,11 +111,17 @@ describe("PlannerGreetingCoordinator", () => { const localPrompt = coordinator.decorateLocalEvent( event(session.id, "prompt.submitted", { prompt: greeting }), ); - expect(localPrompt.payload).toMatchObject({ prompt: greeting }); - expect(localPrompt.payload).not.toHaveProperty("plannerOrigin"); - expect(coordinator.redactForTelemetry(localPrompt).payload).not.toHaveProperty( - "prompt", - ); + expect(localPrompt.payload).toMatchObject({ + prompt: greeting, + plannerOrigin: "infrastructure", + }); + expect(coordinator.redactForTelemetry(localPrompt).payload).toMatchObject({ + planner: true, + origin: "infrastructure", + }); + expect( + coordinator.redactForTelemetry(localPrompt).payload, + ).not.toHaveProperty("prompt"); await coordinator.onEventPersisted( event(session.id, "turn.completed", { assistantText: "What should we build?" }), diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts index e9d6417a5..606b89b75 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/planner-greeting.ts @@ -1246,7 +1246,15 @@ export class PlannerGreetingCoordinator { await this.persist(sessionId, state); if (skipStartup) { this.clearTimer(sessionId); - this.clearCorrelation(sessionId); + if (attemptId) { + // The prompt may already have crossed the PTY boundary without its + // hook arriving yet. Keep a retired correlation so it is still + // projected as infrastructure, while its late completion cannot + // deliver the now-skipped startup turn. + this.retireAttemptCorrelation(sessionId, attemptId); + } else { + this.clearCorrelation(sessionId); + } this.emit({ name: "planner_greeting.skipped", projectId: state.metadata.identity.projectId, From a24ca7d1dcba7e1e9301db2e7035ed81430e5012 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 18:58:45 +0000 Subject: [PATCH 4/5] fix(harness): retain failed startup tombstones --- .../harness/src/core/planner-greeting.test.ts | 40 +++++++++++++++++++ packages/harness/src/core/planner-greeting.ts | 6 ++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts index fe2b568c9..8311436ca 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/planner-greeting.test.ts @@ -139,6 +139,46 @@ describe("PlannerGreetingCoordinator", () => { expect(durable.inputs).toEqual([]); }); + it("keeps an uncertain failed startup classified when the user proceeds", async () => { + let calls = 0; + manager.submitInput = async (_id, text) => { + submitted.push(text); + calls += 1; + if (calls === 1) throw new Error("uncertain PTY write"); + return true; + }; + const coordinator = new PlannerGreetingCoordinator({ + root, + sessionManager: manager, + deliveryTimeoutMs: 60_000, + }); + await coordinator.register(session, { emptyProject: true, mode: "created" }); + const greeting = submitted[0]!; + expect(session.planning?.greeting).toEqual({ + status: "failed", + retryable: true, + errorCode: "injection_failed", + }); + + await coordinator.enqueue(session.id, "continue with my request"); + expect(submitted).toEqual([greeting, "continue with my request"]); + const localPrompt = coordinator.decorateLocalEvent( + event(session.id, "prompt.submitted", { prompt: greeting }), + ); + expect(localPrompt.payload).toMatchObject({ + prompt: greeting, + plannerOrigin: "infrastructure", + }); + expect(coordinator.redactForTelemetry(localPrompt).payload).toMatchObject({ + planner: true, + origin: "infrastructure", + }); + expect(session.planning?.greeting).toEqual({ + status: "skipped", + reason: "user-proceeded", + }); + }); + it("rejects a planner session identity that could escape the queue root", async () => { session = plannerSession("../outside-planner-root"); const coordinator = new PlannerGreetingCoordinator({ diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts index 606b89b75..b3d545700 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/planner-greeting.ts @@ -1169,7 +1169,7 @@ export class PlannerGreetingCoordinator { if (state.inputs.length > 0) { state.metadata.greeting = { status: "skipped", reason: "user-proceeded" }; await this.persist(sessionId, state); - this.clearCorrelation(sessionId); + if (expectedKey === "pending") this.clearCorrelation(sessionId); this.emit({ name: "planner_greeting.skipped", projectId: state.metadata.identity.projectId, @@ -1252,9 +1252,11 @@ export class PlannerGreetingCoordinator { // projected as infrastructure, while its late completion cannot // deliver the now-skipped startup turn. this.retireAttemptCorrelation(sessionId, attemptId); - } else { + } else if (greeting.status === "pending") { this.clearCorrelation(sessionId); } + // A failed attempt's surviving entries were deliberately retired by + // setFailure. Keep those tombstones until delayed hooks consume them. this.emit({ name: "planner_greeting.skipped", projectId: state.metadata.identity.projectId, From b7384928a97f128d1bbcf62187550bbef3c874ca Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 19:03:59 +0000 Subject: [PATCH 5/5] fix(harness): retain staged startup correlation --- .../harness/src/core/planner-greeting.test.ts | 32 ++++++++++++++++++- packages/harness/src/core/planner-greeting.ts | 1 - 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/planner-greeting.test.ts index 8311436ca..90d7ed62a 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/planner-greeting.test.ts @@ -5,7 +5,10 @@ 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 { + SessionInputGuardRejectedError, + type SessionManager, +} from "./session-manager.js"; import { PlannerGreetingCoordinator, PlannerGreetingRetryUnavailableError, @@ -179,6 +182,33 @@ describe("PlannerGreetingCoordinator", () => { }); }); + it("keeps a staged guard rejection classified for a delayed hook", async () => { + manager.submitInput = async (_id, text) => { + submitted.push(text); + throw new SessionInputGuardRejectedError(true); + }; + const coordinator = new PlannerGreetingCoordinator({ + root, + sessionManager: manager, + deliveryTimeoutMs: 60_000, + }); + await coordinator.register(session, { emptyProject: true, mode: "created" }); + const greeting = submitted[0]!; + expect(session.planning?.greeting).toEqual({ + status: "failed", + retryable: false, + errorCode: "session_exited", + }); + + const localPrompt = coordinator.decorateLocalEvent( + event(session.id, "prompt.submitted", { prompt: greeting }), + ); + expect(localPrompt.payload).toMatchObject({ + prompt: greeting, + plannerOrigin: "infrastructure", + }); + }); + it("rejects a planner session identity that could escape the queue root", async () => { session = plannerSession("../outside-planner-root"); const coordinator = new PlannerGreetingCoordinator({ diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/planner-greeting.ts index b3d545700..5a6eb0b5b 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/planner-greeting.ts @@ -1183,7 +1183,6 @@ export class PlannerGreetingCoordinator { } 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,