diff --git a/.changeset/early-maps-start.md b/.changeset/early-maps-start.md new file mode 100644 index 000000000..dccbe478e --- /dev/null +++ b/.changeset/early-maps-start.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +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 d4510d9bc..9bacabeb4 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,19 @@ 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. 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 dd1b46c9e..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, @@ -85,7 +88,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,7 +101,15 @@ 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 }), @@ -107,20 +118,19 @@ describe("PlannerGreetingCoordinator", () => { prompt: greeting, plannerOrigin: "infrastructure", }); - expect(coordinator.redactForTelemetry(localPrompt).payload).not.toHaveProperty( - "prompt", - ); + 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?" }), ); - 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( @@ -132,6 +142,73 @@ 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("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({ @@ -556,7 +633,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 +643,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); @@ -581,6 +662,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,23 +1058,31 @@ describe("PlannerGreetingCoordinator", () => { }); 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"); + 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, 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("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 facd4e80c..5a6eb0b5b 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; @@ -235,21 +237,17 @@ function adoptRehydratedState( export function plannerGreetingPrompt( emptyProject: boolean, - attemptId?: string, + attemptNumber = 1, ): 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 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([ @@ -321,6 +319,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 +343,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 +643,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 }); } @@ -1087,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(); @@ -1162,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, @@ -1176,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, @@ -1226,17 +1232,39 @@ 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); + 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 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, 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 6976fcbad..2134240b9 100644 --- a/packages/harness/src/core/planning-session.test.ts +++ b/packages/harness/src/core/planning-session.test.ts @@ -185,6 +185,13 @@ 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( + "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"); @@ -273,16 +280,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( @@ -302,19 +309,29 @@ 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", ); }); - 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 +345,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..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 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 ? ` ${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"); @@ -331,6 +344,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 +358,7 @@ export class PlanningSessionService { }, { planning: (sessionId) => - planningFor(project.projectId, sessionId, principal, greeting), + planningFor(project.projectId, sessionId, principal, initialGreeting), promptAppendix: (sessionId) => buildFocusedPlannerContext({ project, @@ -456,10 +471,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..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", - "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.", + "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 1f5753d24..1039c61f1 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.", + "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( 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,