diff --git a/.changeset/canvas-resize-release.md b/.changeset/canvas-resize-release.md new file mode 100644 index 000000000..06b3e420f --- /dev/null +++ b/.changeset/canvas-resize-release.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Keep canvas overview resizing responsive when the pointer is released over the embedded canvas. The drag now finishes and its height persists instead of leaving the panel stuck in resize mode. diff --git a/.changeset/native-codex-delegation-startup.md b/.changeset/native-codex-delegation-startup.md new file mode 100644 index 000000000..5c54e2f60 --- /dev/null +++ b/.changeset/native-codex-delegation-startup.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Fix fresh Codex delegation waiting for a transcript that Codex creates only after the first turn. Exact owned children now receive one marked kickoff before transcript discovery, and concurrent rollouts are correlated to that runtime without crossing session identities. Retried requests preserve the same child and do not repeat its kickoff. diff --git a/.changeset/studio-created-agent-membership.md b/.changeset/studio-created-agent-membership.md new file mode 100644 index 000000000..da5b50cb9 --- /dev/null +++ b/.changeset/studio-created-agent-membership.md @@ -0,0 +1,12 @@ +--- +"@sapiom/harness": patch +--- + +Register successfully scaffolded agents immediately under their creating Studio +project, including folders beside its root. Preserve the project's existing +conversation when selecting those agents, and restore membership from recorded +scaffold completions on restart without moving files or creating extra sessions. + +Accept native Codex timed MCP results and relative scaffold targets while preserving exact creator-path and project ownership checks. Previously recorded successful Codex completions recover membership on restart. + +Keep an explicitly selected archived conversation when selecting its project's created agents after restart. Remember that conversation across browser reloads only while its exact session ID remains in the server's state, without resuming a runtime, creating a session, or replaying input. Existing browser preferences remain compatible; absent or stale conversation selections use the existing live-session fallback. diff --git a/.changeset/studio-onboarding-followups.md b/.changeset/studio-onboarding-followups.md new file mode 100644 index 000000000..6a64fb7a3 --- /dev/null +++ b/.changeset/studio-onboarding-followups.md @@ -0,0 +1,21 @@ +--- +"@sapiom/harness": minor +--- + +Deliver a new-agent request directly to Claude Code or Codex at startup after +scaffolding and attachment preparation, without requiring a second Enter or +placing internal authoring instructions in the user's prompt. Preserve project +scope during launch and retain the composer draft when preparation fails. +Session creation and attachment uploads each retain an independent limit of +30 requests per minute, so uploading files does not block a new conversation. + +Add optional `CreateSessionRequest.initialPrompt`, `initialAttachments`, and +`scaffold` fields, `LaunchOpts.initialPrompt` for fresh interactive launches, +and `CREATE_SESSION_JSON_LIMIT_BYTES` for embedders configuring HTTP parsers. +Export `PROJECT_AGENT_PROMPT_APPENDIX` and `projectAgentPromptAppendix` so +embedders can compose Studio's shared project guidance and optional focused +context offline without starting a server. + +Strengthen shared Agent Map, build-plan, and writable subsession guidance while +keeping authoring and runtime capabilities primary. Document project-tool +contracts and replace the known stale Studio orientation at prompt delivery. diff --git a/.changeset/unified-project-agents.md b/.changeset/unified-project-agents.md index e95027a50..128f5a520 100644 --- a/.changeset/unified-project-agents.md +++ b/.changeset/unified-project-agents.md @@ -1,18 +1,28 @@ --- "@sapiom/harness": minor +"@sapiom/harness-desktop": patch --- -Unify Studio project sessions around one ordinary coding-agent identity and -make the project name open the shared Agent Map without starting a session. -Conversation tabs restore their exact session and Canvas independently of the -map and workflow Steps selection. +Unify Agent Studio project sessions around one ordinary coding-agent identity, make the project name open the shared Agent Map, and seed new projects through a durable, retry-safe bootstrap in the first `Plan Agents` session. **Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): `HarnessSession.agentMapIdentity` is now the role-neutral -`ProjectAgentSession { projectId, userId, sessionId }`. Stop branching on the -former `role` or `assignment` fields. Valid persisted legacy metadata is -normalized while session/provider IDs, cwd, title, transcript, and Canvas are -preserved. Malformed or conflicting authority fails closed. Optional -`projectBootstrap` describes lifecycle state only. Generic session routes -revalidate project ownership on resume; migrated startup queues retain their -durable FIFO input boundary during the coordinator transition. +`ProjectAgentSession { projectId, userId, sessionId }`; `role` and `assignment` +are no longer present. `AgentMapToolEvent.role` is also removed; telemetry +consumers use neutral project/session/tool/outcome fields. Valid persisted pre-upgrade session metadata is migrated +into the optional `projectBootstrap` lifecycle field and then removed. Retired +project-session HTTP aliases and public API names are removed; live clients use +the generic session routes. + +**Migration:** stop branching on `agentMapIdentity.role` or `.assignment`, read +optional `projectBootstrap` only for bootstrap status, and use the generic +session routes. An +embedder that already owns a new session's first prompt should send +`initialUserInputPending: true` in the same `CreateSessionRequest`, so automatic +bootstrap yields before launch. New telemetry consumers should recognize the +neutral `project_agent.*` and `project_bootstrap.*` events. Valid legacy state +keeps its session/provider IDs, cwd, title, transcript, and Canvas; malformed or +conflicting authority is retained and fails closed. Released infrastructure +bootstrap event markers remain read-compatible so their private control prompt +never becomes a human transcript turn after upgrade. Downgrading does not +restore the superseded session authority model. diff --git a/docs/plans/agent-studio-plan-first-agent-map/design.md b/docs/plans/agent-studio-plan-first-agent-map/design.md new file mode 100644 index 000000000..1418f0c23 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/design.md @@ -0,0 +1,69 @@ +# Agent Studio unified Agent Map + +## Product authority + +Every Studio project session is one ordinary writable project agent. Its +trusted principal is `{ projectId, userId, sessionId }`, derived by the server. +Assignment, map-node, and focused-brief references are context, never +authorization. Every project session receives the same project-agent prompt, +Agent Map tools, build-plan tools, coding surface, and delegation tool subject +to normal project isolation and capability lifecycle. + +The project owns one durable Agent Map and one current project build plan. +Sessions read and update that shared state through validated tools. A clear +implementation request proceeds directly. Agents update the map or plan only +when work changes architectural boundaries, ownership, contracts, resources, +connectors, artifacts, sequencing, or cross-agent flow. Internal code choices +remain local. + +## Navigation + +The project name selects the production Agent Map. That selection is a +deterministic read of durable state and never creates, resumes, focuses, or +prompts a session. Every session tab selects exactly one ordinary conversation +and its Canvas/Steps surface. A new project starts with one ordinary session +initially named `Plan Agents`; the name and first position grant no special +authority. + +## Bootstrap and continuous maintenance + +Project creation durably schedules one evidence-first bootstrap turn for the +first ordinary session when the map is meaningfully empty. Attempts, readiness, +preemption, retry, restart recovery, and delivery correlation are durable and +idempotent. User input remains usable and wins races without being discarded. +Opening the map does not trigger model work. After bootstrap, the common prompt +makes map maintenance a responsibility of every session. + +## Versions, briefs, and delegation + +Map, plan, and brief content use canonical digests and project-bound immutable +version references. Accepted changes append a version and atomically advance a +current pointer. Concurrent writes use exact expected versions; stale overlap +conflicts require reread/rebase. Restoration appends a new record carrying +`restoredFromVersionId`; history is never rewritten or rewound. + +Focused briefs are deterministic, bounded, exact-source context overlays. They +focus a mission, scope, dependencies, contracts, deliverables, constraints, and +acceptance evidence without changing prompt or tools. Sessions without briefs +retain full capabilities and global context. + +Any project agent may delegate writable work. The coordinator uses stable +project/parent/key bindings, durable spawn claims, exact session reuse, +readiness-gated kickoff delivery, acknowledgement, bounded retention, nested +delegation, and explicit stale-context recovery. Cleanup owns only sessions it +created; unrelated manual sessions are never adopted or mutated. + +## Evidence boundary + +Source and runtime evidence may verify or challenge project intent but never +silently becomes intent. The per-agent execution graph remains the authority for +internal steps and ordinary tool calls. The project map stays at architectural +altitude. + +## Security and observability + +Trusted scope never comes from model arguments. Capabilities are private, +project/session-scoped, rotated on resume, revoked on exit, and rejected across +projects. Telemetry records bounded lifecycle outcomes and identifiers only; it +must not contain prompts, plan prose, source text, paths, credentials, connector +payloads, or raw provider errors. diff --git a/docs/plans/agent-studio-plan-first-agent-map/modules/journey/interfaces.md b/docs/plans/agent-studio-plan-first-agent-map/modules/journey/interfaces.md new file mode 100644 index 000000000..518670a09 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/modules/journey/interfaces.md @@ -0,0 +1,60 @@ +# Unified Agent Map journey interfaces + +## Shared identity + +```ts +type ProjectAgentSession = Readonly<{ + projectId: StudioProjectId; + userId: UserId; + sessionId: SessionId; +}>; +``` + +The server derives this identity for creation, resume, bootstrap, manual +sessions, and delegated sessions. Focus references are carried separately. + +## Navigation seam + +- Project-name selection renders `AgentMapPane` and does not change the active + session ID. +- A session-tab selection activates that exact session and renders the ordinary + conversation plus Canvas/Steps. +- The tab key is the durable session ID; there is one visible tab per live ID. + +## Map and plan seam + +- `GraphContentDigest` identifies canonical semantic graph content. +- `AgentMapVersionRef` binds `projectId`, `versionId`, and `contentDigest`. +- `ProjectAgentActorRef` records trusted user/session attribution. +- `ProjectBuildPlanVersion` is immutable and exact-map-bound. +- `AgentBriefVersion` is immutable and exact map/plan-bound. +- Current reads and historical exact-version reads are distinct operations. +- Apply/rebase/restore append before atomically advancing a pointer. + +All sessions discover `agent_map_read`, `agent_map_validate`, +`agent_map_propose`, `build_plan_read`, `build_plan_validate`, +`build_plan_apply`, `build_plan_rebase`, `build_plan_brief_refresh`, and +`project_subsession_delegate`. + +## Focused-context seam + +A focused projection is allowlisted, deterministic, source-verified, and size +bounded. Authored prose is delimited as untrusted data. The projection excludes +secrets, raw evidence, local paths, connector values, unrelated history, and +arbitrary instructions. It supplements the common project-agent prompt. + +## Delegation seam + +Delegation authority comes from the caller's private project capability. Inputs +contain a stable request key, stable delegation key, assignment, and optional +exact focused-context reference; they contain no trusted project/user/session +selector. Durable claims fence creation, spawning, kickoff, acknowledgement, +release, and restart recovery. Nested delegation uses the same interface and +capabilities. Manual sessions remain outside coordinator ownership. + +## Future journey contracts + +Later shared-context, reconciliation, and existing-project adoption work must +consume these neutral identity, version, brief, and session contracts. Evidence +remains diagnostic; no later issue may add an approval or mode boundary before +ordinary coding or delegation. diff --git a/docs/plans/agent-studio-plan-first-agent-map/rollout-rollback.md b/docs/plans/agent-studio-plan-first-agent-map/rollout-rollback.md new file mode 100644 index 000000000..fb1642657 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/rollout-rollback.md @@ -0,0 +1,39 @@ +# Unified Agent Map rollout and recovery + +## Release gate + +Ship desktop beta first. Before a tag, record the exact main SHA, changeset +files, generated release PR, desktop package version, last known good tag, and +the intended `vX.Y.Z-beta.N` tag. Build and smoke the packaged AppImage, inspect +the packaged resources when runtime files changed, and walk the project-map, +ordinary-session, direct-build, and delegation journeys. + +The npm path is changeset → merged version PR → publish. The desktop path is a +tag exactly matching `packages/harness-desktop/package.json`. Stable release is +allowed only after beta evidence and the update manifests are present. + +## Rollback reality + +There is no in-place downgrade for users who already installed a bad npm or +desktop version. Recovery is roll-forward: + +1. identify and revert the faulty commit on a new branch; +2. add a new changeset and publish a strictly higher package version; +3. build and publish a strictly higher desktop tag; +4. verify installers, `latest*.yml`, and blockmaps remain available for the + last known good and new recovery releases; +5. use deprecation only as an installer warning, never as an unpublish plan. + +Deleting a tag or release cannot downgrade installed desktop applications and +may strand the updater. `SAPIOM_UPDATE_CHANNEL` is a single-machine diagnostic, +not fleet rollback. Record an out-of-hours approver and drill the full +revert→changeset→version-PR→tag sequence before stable rollout. + +## Product-state restoration + +Product restoration is separate from binary rollback. A map or plan restore is +an ordinary expected-version write. It appends a new immutable version whose +content matches the selected historical version and whose +`restoredFromVersionId` names that source. The previous history remains +byte-for-byte unchanged, the current pointer advances atomically, and a stale +restore conflicts like any other concurrent write. diff --git a/packages/harness/README.md b/packages/harness/README.md index 14df5ca5f..281ecf516 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -41,14 +41,16 @@ session lifecycle) to improve Sapiom. Opt out any time; `--no-telemetry` disables collection entirely. Events are also written locally to `~/.sapiom/harness/events.ndjson` for your own inspection. -Project bootstrap adds content-free `project_bootstrap.*` and -`project_agent.identity_*` lifecycle events. They contain bounded project, -session, attempt, retry, queue-depth, and error-code fields. Prompts, assistant -text, local paths, and provider error text remain local. The same telemetry -opt-in controls whether lifecycle events leave the machine. Project hook -projections reduce session-start source to a fixed enum, model identity to a -presence boolean, and usage to allowlisted, clamped token counters; arbitrary -provider strings and usage fields remain local. +Project bootstrap and identity migration add content-free `project_bootstrap.*` +and `project_agent.identity_*` lifecycle events. Navigation distinguishes +`agent_map.entered` from `session.switched`. These events contain bounded +project/session/attempt identifiers, retry ordinals, queue depths, outcomes, and +error codes only. Prompts, assistant text, source text, local paths, connector +payloads, secrets, and raw provider errors remain local. The same telemetry +opt-in controls whether lifecycle events leave the machine. Hook projections +reduce session-start source to a fixed enum, model identity to a presence +boolean, and usage to allowlisted, clamped token counters; arbitrary provider +strings and usage fields remain local. ## Outbound requests @@ -57,9 +59,10 @@ Agent Studio makes one Sapiom request of its own, separate from telemetry 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): -Project bootstrap adds no separate network endpoint. Its state, durable input -FIFO, and recovery stay inside the local server. The initial map seed uses the -coding agent's ordinary provider traffic and existing project tools. +Project bootstrap makes no additional network request. Its attempt +coordination, durable input ordering, 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 @@ -83,52 +86,72 @@ Architecture: a single Node process (Express + ws + node-pty) serves the built SPA, a small REST API, terminal WebSocket streams, and the local telemetry ingest endpoint. The interface contract lives in `src/shared/types.ts`. -### Project sessions and Agent Map navigation - -Every coding-agent session in a project uses the same writable -`ProjectAgentSession { projectId, userId, sessionId }` identity and common -project instructions. Map access comes from that trusted identity. Roles and -agent assignments do not grant a separate kind of session or capability. - -Click the project name to open its shared Agent Map without starting a coding -agent. Conversation tabs identify exact sessions, and selecting a session -restores its own terminal and Canvas. The map selection and agent Steps -view remain independent of the selected conversation. There is no pinned -planning-session tab; existing session titles and conversations are preserved. - -The authenticated local API resolves project identity from durable project -roots. New sessions, resume, and transcript adoption use the generic session -routes. Resuming a project session revalidates its current signed-in owner and -active root binding before launching a process. Nested coding-agent directories -resolve to their containing project instead of inventing duplicate roots. - -**Migration note (breaking):** `HarnessSession.agentMapIdentity` now contains -only project, user, and session IDs. Stop branching on its former `role` or -`assignment` fields. Valid persisted legacy metadata is normalized while -preserving the session/provider IDs, cwd, title, transcript, and Canvas; -conflicting or malformed authority fails closed. Optional `projectBootstrap` -is lifecycle metadata, not an authority or session type. On upgrade, the server migrates legacy startup queues into the durable bootstrap -FIFO. The old planner-session create, message, and retry routes are removed. -Use the ordinary `/api/sessions` create, resume, and input routes. - -When Studio opens a new project, it schedules one automatic first conversation, -named **Plan Agents**, to seed an evidence-supported Agent Map. The catalog -outbox and first-session claim keep this lifecycle recoverable across restart. -The complete coordinator handles readiness, interrupted delivery, retries, -user-input preemption, and shutdown before another turn may be sent. Existing -projects are not enrolled merely by reading their map. - -`POST /api/sessions` accepts the content-free -`initialUserInputPending: true` hint when the caller owns the first prompt. -Client-authored authority and other unknown fields are rejected. The browser -still submits that prompt to the returned exact session after readiness. - -The browser/host token gates `/api` routes and is never injected into a -coding-agent PTY. Each PTY receives a separate `/ingest` capability bound to its -session ID. Vendor resume pointers remain pinned to one harness session; -current owners and durable historical aliases cannot be adopted into another -session. Duplicate persisted provider IDs are repaired conservatively during -boot, preserving the first owner and clearing the later duplicate pointer. +### Project sessions and Agent Map bootstrap + +Every session whose working directory resolves to a Studio project is an +ordinary writable coding session with the same server-derived +`{ projectId, userId, sessionId }` principal, project-agent prompt appendix, and +Agent Map tools. Assignment or bootstrap metadata is context only and cannot +change the prompt profile, tools, filesystem policy, or implementation +authority. + +Clicking a project name opens its durable Agent Map without creating, resuming, +focusing, or prompting a session. Every tab represents one real session ID and +opens that session's ordinary conversation and Canvas/Steps experience. A new +project's first ordinary session is initially titled **Plan Agents**; the title +does not confer a role and can be renamed like any other session. + +When a new project gains its first active root binding, Studio durably schedules +one evidence-first map bootstrap for that first session. The model reads the +current map and uses the same structured tools available to every project +session. It only proposes an initial map while the durable map remains +meaningfully empty. Attempt IDs, retry ordinals, readiness and model-turn +timeouts, terminal outcomes, and input-delivery acknowledgements survive +restart. Real user input has priority: an initial prompt prevents bootstrap +from starting, and later input preempts a pending or still-staged attempt. If +the bootstrap Enter may already have crossed the PTY boundary, the user input +is durably accepted and held until a correlated completion or process restart +proves that turn cannot overlap; prompts are never concatenated or blindly +interleaved. Opening the map never schedules bootstrap. + +Bootstrap state lives under +`/agent-map/project-bootstrap/`. Valid pre-upgrade session metadata +and queue files are read and normalized without changing the session ID, +provider binding, working directory, title, transcript, or Canvas. Malformed or +ambiguous legacy identity is retained and rejected safely rather than deleting +or duplicating the session. Retired record strings live only in dedicated, +tested migration decoders. Live clients use the generic session routes. + +#### Embedder migration + +The public `HarnessSession.agentMapIdentity` is now the exported +`ProjectAgentSession { projectId, userId, sessionId }`. Embedders must stop +reading the removed `role` and `assignment` fields; those fields no longer +describe live authority. `AgentMapToolEvent.role` is also removed; consumers use +neutral project, session, tool, and outcome fields. Persisted pre-upgrade +project-session data is migration input only. +Read the optional `projectBootstrap` field when displaying bootstrap lifecycle +state. If an embedder already owns the first prompt for a session, set +`initialUserInputPending: true` in that session's `CreateSessionRequest`; this +content-free flag makes project bootstrap yield before launch and never changes +the session's authority or tools. + +To deliver the first task as part of session creation, send `initialPrompt` +with optional `initialAttachments` and `scaffold: { template }` in +`CreateSessionRequest`. Attachments accept `{ kind: "path", path }` references or inline +`{ kind: "inline", filename, dataUrl }` data. Studio prepares the scaffold and +attachments before launching the CLI with that first task. Adapter authors +receive it as `LaunchOpts.initialPrompt` on fresh launches; resume does not +replay it. Embedders that configure their own HTTP body parser can use the +exported `CREATE_SESSION_JSON_LIMIT_BYTES` for this route. Session creation +and attachment uploads each allow 30 requests per minute in independent +buckets. + +The browser/host token gates `/api` routes and is never injected into a coding +agent PTY. Each PTY instead receives session-bound ingest and Agent Map +capabilities. Project scope is re-derived from trusted server state before every +launch or resume; capabilities rotate on resume, revoke on exit or principal +change, expire when inactive, and fail closed outside their project. ### Project contract helpers @@ -139,6 +162,13 @@ For example, use `parseProjectBuildPlanVersion` to validate a plan record and of timestamps or attribution. These data contracts do not require a live session or an active MCP tool. Store and tool activation are separate integrations. +For offline prompt composition, `PROJECT_AGENT_PROMPT_APPENDIX` provides the +common Studio project guidance and `projectAgentPromptAppendix(focusedContext?)` +appends an optional already-rendered `FocusedSessionContextProjection`. These +supported exports let an embedder reuse Studio's instructions without starting +a server. Use the returned string as prompt content; its wording evolves with +Studio guidance. + `BuildPlanId`, `ArchitectureSourceRef`, `AgentMapRevisionId`, `AgentBriefVersionRecord`, and `computeArchitectureGraphDigest` are supported aliases for the corresponding neutral plan, map and brief contracts; they do @@ -165,10 +195,14 @@ Every trusted project session receives the same nine project-wide tools: shared state or allocating permanent IDs. - `agent_map_propose` atomically and idempotently applies one validated batch to the shared Proposed map. -- `build_plan_read` reads the current plan or an exact historical version. -- `build_plan_validate` previews a plan replacement without writing it. -- `build_plan_apply` commits an idempotent plan replacement. -- `build_plan_rebase` reconciles the current plan against an exact map version. +- `build_plan_read` reads the current plan or one exact immutable historical + version. +- `build_plan_validate` previews the same strict request accepted by apply + without writing state or consuming IDs. +- `build_plan_apply` atomically appends an idempotent plan version using exact + expected map and plan references. +- `build_plan_rebase` moves the current plan between exact map versions using + explicit remap or removal resolutions. - `build_plan_brief_refresh` refreshes canonical or focused context from exact sources. - `project_subsession_delegate` creates or reuses writable child sessions, refreshes focused context, releases owned children, or reclaims dormant bindings. @@ -178,6 +212,12 @@ or explicitly re-referenced coordinator-owned sessions per project. Readiness waits share a 30-second batch budget; partial `readiness_timeout` results can be retried explicitly with the same request key and durable session identities. +The map and plan use append-only immutable histories with optimistic +concurrency. Roles, assignment completeness, proposal state, and focused brief +availability never determine whether a session may use these tools or write +code. See [`docs/shared-build-plan.md`](docs/shared-build-plan.md) for the +version, replay, rebase, and brief-storage contracts. + HTTP contracts that need more than a type to use are written up under `docs/`: - [`docs/agent-canvas-graph.md`](docs/agent-canvas-graph.md) — the session-free diff --git a/packages/harness/docs/shared-build-plan.md b/packages/harness/docs/shared-build-plan.md index 6f1a8342b..a8cb4e1e4 100644 --- a/packages/harness/docs/shared-build-plan.md +++ b/packages/harness/docs/shared-build-plan.md @@ -6,6 +6,47 @@ map and build-plan MCP tools. Trusted `{ projectId, userId, sessionId }` scope comes only from the private session capability; tool input cannot select or override it. +## Coding-agent guidance and discovery + +Agent delivery remains the priority: scaffold/check/local testing and runtime +capability guidance are not replaced by project coordination. The shared +`profiles/project-agent.ts` appendix teaches all nine project tools to every +ordinary writable project session, including focused children. MCP descriptions +and schema field descriptions carry the detailed request/reference contracts so +the startup prompt need not reproduce full schemas. + +The Agent Map is distinct from the automatic per-agent Canvas. During +implementation, establish a real initial map once boundaries are clear and keep +meaningful agents, contracts, artifacts, and data flow current. Saving a new +`summary.md`, for example, warrants an artifact and writes/reads relationships +even without adding an agent. Read, validate, propose, and verify the saved map +before claiming it is current; validation alone does not persist anything. +Read-only requests still authorize no project-state mutation. + +Use a build plan when coordination needs assignments, sequencing, or acceptance +criteria, not as a prerequisite for a small edit. Obtain exact references from +`build_plan_read({kind: "current"})`, not the compatibility map snapshot. Plan +tool input references omit `projectId`; delegation focus references include it. +After a map-version change, rebase an existing plan before applying plan edits. +Canonical brief refresh is already attempted after apply/rebase and can be +retried independently of the accepted plan. + +Delegated sessions are writable execution contexts sharing the parent's cwd, +not automatically isolated worktrees. Specify disjoint ownership and written +deliverables. A ready session or acknowledged kickoff is not proof the task is +finished; inspect/test its deliverables. Creating a map node, plan, or brief +does not automatically delegate work. + +Startup materialization upgrades only the exact known legacy base-prompt +revision pinned by `profiles/fixtures/legacy-system-prompt.md`; unknown/custom +remote prompts remain untouched. This prevents the old two-MCP, sample-project, +and stop-for-an-invitation orientation from returning while a backend still +serves that revision. The common project appendix also states current +orientation precedence. Keep the current fallback digest and backend served +copy synchronized when publishing; the fixture's historical digest must not +move with the current prompt pin. Local delivery tests do not prove a backend +deployment or that a model consistently follows the instructions. + ## Digests and exact references `GraphContentDigest` identifies canonical graph semantics without project, @@ -173,8 +214,16 @@ The budget bounds readiness waiting; in-flight durable writes and process creati finish before their result is reported. Trusted tests or hosts can lower the budget with `batchWaitTimeoutMs`, but cannot increase it beyond 30 seconds. -The coordinator waits for canonical adapter readiness and exact transcript -identity, then uses fenced spawn and delivery epochs to submit one kickoff. +The coordinator waits for canonical adapter readiness and uses fenced spawn and +delivery epochs to submit one kickoff. Fresh Codex produces its rollout only after +that first turn, so an exact privately owned Codex runtime with a pending first +kickoff can submit before transcript discovery. The host prefixes that prompt +with a non-secret runtime digest marker; its rollout broker requires the same +marker before attributing the transcript. Ordinary pending runtimes cannot claim +marked child rollouts, and metadata-only files remain unassigned until the real +first user turn is available. Existing or ambiguous identities retain the usual +gates. A submitted kickoff restarts collection if an idle runtime outlived the +initial discovery window; it never starts another kickoff. Delivery states distinguish pending, claimed, submitted without acknowledgement, acknowledged, and uncertain. An uncertain delivery is never resent blindly. Exact focused references are checked before delivery, and stale context returns diff --git a/packages/harness/src/core/adapters/claude-code.test.ts b/packages/harness/src/core/adapters/claude-code.test.ts index c0372cf92..f1448996d 100644 --- a/packages/harness/src/core/adapters/claude-code.test.ts +++ b/packages/harness/src/core/adapters/claude-code.test.ts @@ -27,6 +27,8 @@ describe("ClaudeCodeAdapter", () => { adapter.detectBlockingPrompt("\x1b[1mDo you trust the files in this folder?\x1b[0m"), ).toBe(true); expect(adapter.detectBlockingPrompt("Do you trust the files in this directory?")).toBe(true); + expect(adapter.detectBlockingPrompt("Quick safety check: Is this a project you created or one you trust?")).toBe(true); + expect(adapter.detectBlockingPrompt("Yes, I trust this folder")).toBe(true); expect(adapter.detectBlockingPrompt("Choose the text style that looks best")).toBe(true); expect(adapter.detectBlockingPrompt("Select login method:")).toBe(true); expect(adapter.detectBlockingPrompt("> welcome, composer is ready")).toBe(false); @@ -66,6 +68,17 @@ describe("ClaudeCodeAdapter", () => { }); describe("launch/resume", () => { + it("hands the initial user task to the CLI once, separately from startup instructions", () => { + const adapter = new ClaudeCodeAdapter({ binary: "fake-claude" }); + const opts = { + harnessSessionId: "first-task", + cwd: "/tmp/proj", + initialPrompt: "--help\nBuild my ticket triage agent", + }; + expect(adapter.launch(opts).args.slice(-2)).toEqual(["--", opts.initialPrompt]); + expect(adapter.resume("native-id", opts).args).not.toContain(opts.initialPrompt); + }); + it("builds a launch SpawnSpec with settings/mcp-config/system-prompt flags and unsets CLAUDECODE", async () => { const promptDir = await mkdtemp(join(tmpdir(), "harness-claude-test-")); const promptFile = join(promptDir, "prompt.txt"); diff --git a/packages/harness/src/core/adapters/claude-code.ts b/packages/harness/src/core/adapters/claude-code.ts index 0f13b5991..bef40c740 100644 --- a/packages/harness/src/core/adapters/claude-code.ts +++ b/packages/harness/src/core/adapters/claude-code.ts @@ -388,6 +388,7 @@ function buildInteractiveConfigArgs(opts: LaunchOpts): string[] { export const CLAUDE_BLOCKING_PROMPT_PATTERNS: readonly RegExp[] = [ // First-run / new-directory trust dialog. /do\s+you\s+trust\s+the\s+files\s+in\s+this\s+(folder|directory)/i, + /quick\s*safety\s*check|yes,?\s*i\s*trust\s*this\s*folder/i, // First-run theme picker. /choose\s+the\s+text\s+style/i, // Signed-out login flow. @@ -475,6 +476,9 @@ export class ClaudeCodeAdapter implements HarnessAdapter { if (opts.systemPromptFile) { args.push("--append-system-prompt", readPromptFile(opts.systemPromptFile)); } + // A positional initial task is consumed by Claude after onboarding/trust. + // Do not paste it into the PTY or interpret leading dashes as CLI options. + if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, args, diff --git a/packages/harness/src/core/adapters/codex.test.ts b/packages/harness/src/core/adapters/codex.test.ts index b371fa9c0..c452b6ab2 100644 --- a/packages/harness/src/core/adapters/codex.test.ts +++ b/packages/harness/src/core/adapters/codex.test.ts @@ -26,6 +26,13 @@ function userMessageLine(message: string): string { describe("CodexAdapter", () => { describe("launch/resume", () => { + it("passes the initial user task as one positional argument on fresh launch only", () => { + const adapter = new CodexAdapter({ binary: "fake-codex" }); + const opts = { harnessSessionId: "first-task", cwd: "/tmp/proj", initialPrompt: "--help\nBuild a ticket triage agent" }; + expect(adapter.launch(opts).args.slice(-2)).toEqual(["--", opts.initialPrompt]); + expect(adapter.resume("native-id", opts).args).not.toContain(opts.initialPrompt); + }); + it("builds a launch SpawnSpec with update check off, never-ask approvals, workspace-write sandbox, and no env overrides", () => { const adapter = new CodexAdapter({ binary: "fake-codex" }); const spec = adapter.launch({ harnessSessionId: "h1", cwd: "/tmp/proj" }); diff --git a/packages/harness/src/core/adapters/codex.ts b/packages/harness/src/core/adapters/codex.ts index 215e352e2..f9cc0d5c1 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -300,9 +300,11 @@ export class CodexAdapter implements HarnessAdapter { } launch(opts: LaunchOpts): SpawnSpec { + const args = buildConfigArgs(opts); + if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, - args: buildConfigArgs(opts), + args, // Codex has no analog to Claude's CLAUDECODE nested-agent guard; no env // overrides are needed for a fresh launch. env: opts.agentMapMcp diff --git a/packages/harness/src/core/agent-map-proposal-schema.ts b/packages/harness/src/core/agent-map-proposal-schema.ts index cf8261dd8..fc769d234 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.ts @@ -55,7 +55,7 @@ const stripUndefinedProperties = >( export const nodeRefSchema = z.union([ z.object({ nodeId: planNodeIdSchema }).strict(), z.object({ draftRef: draftRefSchema }).strict(), -]); +]).describe("Use nodeId from a prior read/propose for existing nodes; draftRef points to a node added in this same atomic batch."); const nodeChangesSchema = z .object({ @@ -80,14 +80,14 @@ const relationshipChangesSchema = z const addNodeSchema = z .object({ kind: z.literal("add-node"), - draftRef: draftRefSchema, + draftRef: draftRefSchema.describe("Caller-chosen local name for this new node; use the returned allocated node ID in later batches."), node: z .object({ - kind: z.enum(PLAN_NODE_KINDS), + kind: z.enum(PLAN_NODE_KINDS).describe("Architectural role: deployable agent, meaningful subagent, shared resource, connector, or input/output artifact. Not every internal function or file is a node."), name: boundedText(160), purpose: boundedText(2_000), - ownerAgent: nodeRefSchema.nullable(), - contractRefs: contractRefsSchema, + ownerAgent: nodeRefSchema.nullable().describe("Semantic owning agent when applicable; this does not grant coding authority or bind a runtime session."), + contractRefs: contractRefsSchema.describe("Relevant input/output/interface contracts; [] when none are established."), }) .strict(), }) @@ -114,7 +114,7 @@ const addRelationshipSchema = z from: nodeRefSchema, to: nodeRefSchema, kind: z.enum(RELATIONSHIP_KINDS), - executionMode: z.enum(EXECUTION_MODES).nullable(), + executionMode: z.enum(EXECUTION_MODES).nullable().describe("Actual sequencing when applicable; use human-triggered for a manual handoff, not an invented automatic pipeline. null for non-execution relationships."), contractRef: contractRefSchema.nullable(), description: boundedText(2_000, true), }) diff --git a/packages/harness/src/core/build-plan-schema.ts b/packages/harness/src/core/build-plan-schema.ts index 7ededc7e4..cc5247b05 100644 --- a/packages/harness/src/core/build-plan-schema.ts +++ b/packages/harness/src/core/build-plan-schema.ts @@ -31,12 +31,12 @@ const identityKey = (value: string | { clientRef: string }) => export const toolMapVersionRefSchema = z.object({ versionId: generatedId("mapv"), contentDigest: digest, -}).strict(); +}).strict().describe("Exact map version from build_plan_read: copy versionId and contentDigest only, omitting projectId. Never derive a digest from the numeric proposal version."); export const toolPlanVersionRefSchema = z.object({ planId: generatedId("plan"), versionId: generatedId("planv"), semanticDigest: digest, -}).strict(); +}).strict().describe("Exact plan reference from build_plan_read: copy planId, versionId, and semanticDigest only, omitting projectId."); const focusedBriefSelectionSchema = z.object({ focusScope: z.object({ @@ -53,7 +53,7 @@ const focusedBriefSelectionSchema = z.object({ export const agentBriefRefreshRequestSchema = z.object({ schemaVersion: z.literal(1), - requestId: opaque.refine(isCallerProjectRequestId, "reserved request namespace"), + requestId: opaque.refine(isCallerProjectRequestId, "reserved request namespace").describe("Caller-chosen identity: identical retries reuse this ID; changed request content needs a fresh ID."), expectedMap: toolMapVersionRefSchema, expectedPlan: toolPlanVersionRefSchema, focus: z.discriminatedUnion("mode", [ @@ -62,7 +62,7 @@ export const agentBriefRefreshRequestSchema = z.object({ selections: unique(focusedBriefSelectionSchema, (selection) => `${selection.focusScope.delegationKey}\0${selection.focusScope.parentScopeKey ?? ""}`), }).strict(), - ]), + ]).describe("canonical compiles workstream briefs; focused compiles explicit ad-hoc selections. Does not inject a new prompt into a running child."), }).strict(); const milestone = z.object({ @@ -130,14 +130,14 @@ export const buildPlanContentInputSchema = z.object({ const replaceContentOperation = z.object({ op: z.literal("replace-content"), - content: buildPlanContentInputSchema, + content: buildPlanContentInputSchema.describe("Complete replacement, not a patch: supply every collection, preserve unrelated intent, reuse existing IDs, and use {clientRef:'local-name'} for new plan-owned IDs."), }).strict(); export const buildPlanApplyRequestSchema = z.object({ schemaVersion: z.literal(1), - requestId: opaque.refine(isCallerProjectRequestId, "reserved request namespace"), + requestId: opaque.refine(isCallerProjectRequestId, "reserved request namespace").describe("Reuse for the same validate/apply request and identical retries; changed content needs a new ID."), expectedMap: toolMapVersionRefSchema, - expectedPlan: toolPlanVersionRefSchema.nullable(), + expectedPlan: toolPlanVersionRefSchema.nullable().describe("Current buildPlan reference from build_plan_read, omitting projectId; null only if creating the first plan."), operations: z.tuple([replaceContentOperation]), }).strict(); @@ -150,11 +150,12 @@ const rebaseResolution = z.discriminatedUnion("kind", [ export const buildPlanRebaseRequestSchema = z.object({ schemaVersion: z.literal(1), - requestId: opaque.refine(isCallerProjectRequestId, "reserved request namespace"), + requestId: opaque.refine(isCallerProjectRequestId, "reserved request namespace").describe("Stable ID for identical rebase retries; changed resolutions or source expectations need a new ID."), expectedPlan: toolPlanVersionRefSchema, - fromMap: toolMapVersionRefSchema, - toMap: toolMapVersionRefSchema, - resolutions: unique(rebaseResolution, (resolution) => JSON.stringify(resolution)), + fromMap: toolMapVersionRefSchema.describe("The current plan's bound map: read plan.map and omit projectId."), + toMap: toolMapVersionRefSchema.describe("The latest map: read current.map and omit projectId, even when its content digest is unchanged."), + resolutions: unique(rebaseResolution, (resolution) => JSON.stringify(resolution)) + .describe("Use [] when references remain valid; explicitly resolve invalidated node/assignment/dependency/repository references, preserving unrelated intent."), }).strict(); export const buildPlanReadRequestSchema = z.discriminatedUnion("kind", [ @@ -169,7 +170,7 @@ export const buildPlanReadRequestSchema = z.discriminatedUnion("kind", [ * the request through `buildPlanReadRequestSchema` again. */ export const buildPlanReadToolInputSchema = z.object({ - kind: z.enum(["current", "exact"]), + kind: z.enum(["current", "exact"]).describe("current: send only kind. exact: also supply planId, versionId, and semanticDigest for one immutable historical version."), planId: generatedId("plan").optional(), versionId: generatedId("planv").optional(), semanticDigest: digest.optional(), diff --git a/packages/harness/src/core/collector/codex-rollout-broker.test.ts b/packages/harness/src/core/collector/codex-rollout-broker.test.ts index 1cd11daf4..72a885367 100644 --- a/packages/harness/src/core/collector/codex-rollout-broker.test.ts +++ b/packages/harness/src/core/collector/codex-rollout-broker.test.ts @@ -6,8 +6,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CodexRolloutBroker } from "./codex-rollout-broker.js"; import * as codexTailer from "./codex-tailer.js"; -const meta = (id: string, cwd: string, timestamp: string) => - `${JSON.stringify({ type: "session_meta", payload: { id, cwd, timestamp } })}\n`; +const meta = (id: string, cwd: string, timestamp: string, ordinaryTurn = true) => + `${JSON.stringify({ type: "session_meta", payload: { id, cwd, timestamp } })}\n` + + (ordinaryTurn ? `${JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "Ordinary initial request" } })}\n` : ""); describe("CodexRolloutBroker", () => { const roots: string[] = []; @@ -74,6 +75,42 @@ describe("CodexRolloutBroker", () => { ).resolves.toEqual({ outcome: "claimed", path: secondPath }); }); + it("correlates simultaneous child rollouts by their exact initial runtime markers", async () => { + const { home, cwd, sessions } = await fixture(); + const marker = (digit: string) => ``; + const input = (sessionId: string, digit: string) => ({ sessionId, runtimeEpoch: `runtime-${digit}`, cwd, + sinceMs: Date.parse("2026-09-04T10:00:00.000Z"), requiredRuntimeMarker: marker(digit) }); + const broker = new CodexRolloutBroker(home); + const first = input("child-a", "1"); const second = input("child-b", "2"); + broker.register(first); broker.register(second); + for (const [name, digit] of [["child-a", "1"], ["child-b", "2"]]) { + await writeFile(join(sessions, `${name}.jsonl`), meta(name!, cwd, "2026-09-04T10:00:01.000Z", false) + + JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [ + { type: "input_text", text: `${marker(digit!)}\n\nImplement the task.` }, + ] } }) + "\n"); + } + await expect(broker.claimFresh(first)).resolves.toEqual({ outcome: "claimed", path: join(sessions, "child-a.jsonl") }); + await expect(broker.claimFresh(second)).resolves.toEqual({ outcome: "claimed", path: join(sessions, "child-b.jsonl") }); + }); + + it("does not let an ordinary pending runtime claim a delegated child's marked rollout", async () => { + const { home, cwd, sessions } = await fixture(); + const requiredRuntimeMarker = ``; + const ordinary = { sessionId: "aaa-ordinary", runtimeEpoch: "ordinary-runtime", cwd, sinceMs: 0 }; + const child = { sessionId: "child", runtimeEpoch: "child-runtime", cwd, sinceMs: 0, requiredRuntimeMarker }; + const broker = new CodexRolloutBroker(home); + broker.register(ordinary); broker.register(child); + const rollout = join(sessions, "marked-child.jsonl"); + await writeFile(rollout, meta("child-native", cwd, "2026-09-04T10:00:01.000Z", false)); + await expect(broker.claimFresh(ordinary)).resolves.toEqual({ outcome: "pending", path: null }); + await writeFile(rollout, meta("child-native", cwd, "2026-09-04T10:00:01.000Z", false) + + JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [ + { type: "input_text", text: `${requiredRuntimeMarker}\n\nImplement the task.` }, + ] } }) + "\n"); + await expect(broker.claimFresh(ordinary)).resolves.toEqual({ outcome: "pending", path: null }); + await expect(broker.claimFresh(child)).resolves.toEqual({ outcome: "claimed", path: rollout }); + }); + it("fails closed when same-root process epochs cannot distinguish candidates", async () => { const { home, cwd, sessions } = await fixture(); const sinceMs = Date.parse("2026-09-04T10:00:00.000Z"); diff --git a/packages/harness/src/core/collector/codex-rollout-broker.ts b/packages/harness/src/core/collector/codex-rollout-broker.ts index 98e0d1163..af07eff1f 100644 --- a/packages/harness/src/core/collector/codex-rollout-broker.ts +++ b/packages/harness/src/core/collector/codex-rollout-broker.ts @@ -12,6 +12,7 @@ type PendingRuntime = Readonly<{ runtimeEpoch: string; cwd: string; sinceMs: number; + requiredRuntimeMarker?: string; }>; const runtimeKey = (sessionId: string, runtimeEpoch: string) => @@ -97,6 +98,9 @@ export class CodexRolloutBroker { sinceMs: pending.sinceMs, homeDir: this.homeDir, excludePaths: new Set(this.claimedPaths.keys()), + ...(pending.requiredRuntimeMarker + ? { requiredRuntimeMarker: pending.requiredRuntimeMarker } + : { excludeRuntimeMarkers: true }), }), ); } diff --git a/packages/harness/src/core/collector/codex-runtime-marker.ts b/packages/harness/src/core/collector/codex-runtime-marker.ts new file mode 100644 index 000000000..ee0434a01 --- /dev/null +++ b/packages/harness/src/core/collector/codex-runtime-marker.ts @@ -0,0 +1,10 @@ +import { canonicalDigest } from "../../shared/agent-map-canonical.js"; + +/** Non-secret correlation proof injected by the host into a fresh child kickoff. */ +export const codexRuntimeMarker = (runtimeEpoch: string): string => + ``; + +export const parseCodexRuntimeMarker = (prompt: string): string | null => { + const firstLine = prompt.split("\n", 1)[0]?.trimEnd() ?? ""; + return /^$/u.test(firstLine) ? firstLine : null; +}; diff --git a/packages/harness/src/core/collector/codex-tailer.ts b/packages/harness/src/core/collector/codex-tailer.ts index a2d4a771f..38f926f78 100644 --- a/packages/harness/src/core/collector/codex-tailer.ts +++ b/packages/harness/src/core/collector/codex-tailer.ts @@ -19,6 +19,8 @@ import { open, readdir, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { parseCodexRuntimeMarker } from "./codex-runtime-marker.js"; + import type { ClaudeHookEvent, RawHookPayload } from "./normalizer.js"; const DEFAULT_POLL_INTERVAL_MS = 300; @@ -271,6 +273,10 @@ export interface FindRolloutFileOptions { homeDir?: string; /** Exact paths already owned by another live Harness runtime. */ excludePaths?: ReadonlySet; + /** Exact first-turn marker required for a coordinator-owned fresh runtime. */ + requiredRuntimeMarker?: string; + /** Ordinary runtimes must wait for an unmarked user turn before claiming. */ + excludeRuntimeMarkers?: boolean; } export interface CodexRolloutCandidate { @@ -322,6 +328,36 @@ async function readSessionMetaHead(filePath: string, maxBytes = 65_536): Promise return null; } +async function readUserTurnMarker(filePath: string): Promise<{ available: boolean; marker: string | null }> { + let content: string; + try { + const handle = await open(filePath, "r"); + try { + const length = Math.min((await handle.stat()).size, 1_048_576); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + content = buffer.subarray(0, bytesRead).toString("utf8"); + } finally { await handle.close(); } + } catch { return { available: false, marker: null }; } + for (const line of content.split("\n")) { + let value: RolloutLine; + try { value = JSON.parse(line) as RolloutLine; } catch { continue; } + const payload = value.payload; + if (value.type === "response_item" && payload?.role === "user" && Array.isArray(payload.content)) { + for (const part of payload.content) { + if (part?.type !== "input_text" || typeof part.text !== "string") continue; + const marker = parseCodexRuntimeMarker(part.text); + if (marker) return { available: true, marker }; + } + } + // A metadata-only file or initial environment-context user message is not + // enough: its real first prompt may still carry a delegated runtime marker. + if (value.type === "event_msg" && payload?.type === "user_message" && typeof payload.message === "string") + return { available: true, marker: parseCodexRuntimeMarker(payload.message) }; + } + return { available: false, marker: null }; +} + async function collectRolloutFiles(dir: string, depth = 0): Promise { if (depth > MAX_SCAN_DEPTH) return []; let entries: import("node:fs").Dirent[]; @@ -389,6 +425,12 @@ export async function findRolloutCandidates( } if (options.sinceMs !== undefined && meta.timestampMs !== null && meta.timestampMs < options.sinceMs) continue; + if (options.requiredRuntimeMarker !== undefined || options.excludeRuntimeMarkers) { + const evidence = await readUserTurnMarker(filePath); + if (!evidence.available || (options.requiredRuntimeMarker !== undefined + ? evidence.marker !== options.requiredRuntimeMarker + : evidence.marker !== null)) continue; + } const fileStat = await stat(filePath).catch(() => null); if (!fileStat) continue; diff --git a/packages/harness/src/core/created-agent-registration.test.ts b/packages/harness/src/core/created-agent-registration.test.ts new file mode 100644 index 000000000..be303940f --- /dev/null +++ b/packages/harness/src/core/created-agent-registration.test.ts @@ -0,0 +1,267 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AnalyticsEvent } from "../shared/types.js"; +import { createEventStore } from "./collector/store.js"; +import { + CreatedAgentRegistration, + scaffoldCompletion, +} from "./created-agent-registration.js"; +import { StudioWorkspacePreferenceStore } from "./studio-workspace-preferences.js"; + +function completion( + dir: string, + changes: Record = {}, +): AnalyticsEvent { + return { + eventId: "event-a", + seq: 1, + ts: new Date().toISOString(), + userId: null, + tenantId: null, + machineId: "machine-a", + harness: "claude-code", + agentSessionId: null, + type: "tool.call", + harnessSessionId: "session-a", + payload: { + toolName: "mcp__sapiom__sapiom_dev_agents_scaffold", + toolInput: JSON.stringify({ dir }), + toolResponseSummary: JSON.stringify([ + { + type: "text", + text: JSON.stringify({ + targetDir: dir, + projectName: "reviewer", + dependenciesInstalled: true, + gitInitialized: true, + }), + }, + ]), + ...changes, + }, + }; +} + +function codexCompletion(dir: string): AnalyticsEvent { + return { + ...completion(dir), + harness: "codex", + payload: { + toolName: "sapiom_dev_agents_scaffold", + toolInput: JSON.stringify({ dir, template: "default" }), + // Native Codex records MCP output in this timed text envelope; + // scaffold may return the same relative directory supplied by the caller. + toolResponseSummary: `Wall time: 5.6901 seconds\nOutput:\n${completion(dir).payload.toolResponseSummary}`, + }, + }; +} + +describe("scaffold completion evidence", () => { + it("reads Claude content and Codex MCP envelopes", () => { + const event = completion("/tmp/reviewer"); + expect(scaffoldCompletion(event)).toEqual({ + dir: "/tmp/reviewer", + targetDir: "/tmp/reviewer", + }); + expect( + scaffoldCompletion( + completion("/tmp/reviewer", { + toolName: "sapiom_dev_agents_scaffold", + toolResponseSummary: JSON.stringify({ + isError: false, + content: JSON.parse(event.payload.toolResponseSummary as string), + }), + }), + ), + ).not.toBeNull(); + }); + it("reads a native Codex timed MCP result with a relative target directory", () => { + expect(scaffoldCompletion(codexCompletion("../reviewer"))).toEqual({ + dir: "../reviewer", + targetDir: "../reviewer", + }); + }); + it.each([ + "Wall time: unknown seconds\nOutput:\n", + "Scaffold succeeded\nOutput:\n", + "Error: scaffold failed\nOutput:\n", + ])("rejects unrecognized output prefixes: %s", (prefix) => { + const event = codexCompletion("../reviewer"); + event.payload.toolResponseSummary = prefix + completion("../reviewer").payload.toolResponseSummary; + expect(scaffoldCompletion(event)).toBeNull(); + }); + it("does not interpret a timed scaffold error as a successful creation", () => { + const event = codexCompletion("../reviewer"); + event.payload.toolResponseSummary = `Wall time: 1 seconds\nOutput:\n${JSON.stringify({ + isError: true, + content: JSON.parse(completion("../reviewer").payload.toolResponseSummary as string), + })}`; + expect(scaffoldCompletion(event)).toBeNull(); + }); + it.each([ + { toolName: "Bash" }, + { toolName: "mcp__evil__not_sapiom_dev_agents_scaffold" }, + { toolInput: "{truncated" }, + { toolResponseSummary: "Scaffold succeeded" }, + { toolResponseSummary: JSON.stringify({ targetDir: "/tmp/reviewer" }) }, + { + toolResponseSummary: JSON.stringify({ + isError: true, + content: JSON.parse( + completion("/tmp/reviewer").payload.toolResponseSummary as string, + ), + }), + }, + ])("ignores incomplete or failed evidence: %j", (changes) => { + expect(scaffoldCompletion(completion("/tmp/reviewer", changes))).toBeNull(); + }); +}); + +describe("created agent registration", () => { + const temporary: string[] = []; + afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + async function fixture() { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "created-agent-")), + ); + temporary.push(root); + const cwd = path.join(root, "original"); + const target = path.join(root, "reviewer"); + await fs.mkdir(cwd); + await fs.mkdir(target); + await fs.writeFile(path.join(target, "sapiom.json"), "{}"); + const preferences = new StudioWorkspacePreferenceStore( + path.join(root, "prefs.json"), + ); + const events = createEventStore(path.join(root, "events.ndjson")); + const projectId = "project_00000000-0000-4000-8000-000000000001"; + const authorize = vi.fn( + async () => + ({ projectId, cwd }) as { projectId: string; cwd: string } | null, + ); + const projectForPath = vi.fn(async () => null as string | null); + const watch = vi.fn(); + const scan = vi.fn(async (agentPath: string) => { + // Publication must already see membership, with no reload or parent scan. + expect( + await preferences.agentIds( + projectId, + [cwd], + [{ path: agentPath, name: "reviewer", definitionId: null }], + true, + ), + ).toHaveProperty("size", 1); + }); + const options = { + preferences, + events, + authorize, + projectForPath, + scan, + watch, + }; + return { + ...options, + cwd, + target, + projectId, + registrar: new CreatedAgentRegistration(options), + options, + }; + } + it("registers before immediate targeted discovery and idempotently recovers after restart", async () => { + const f = await fixture(); + const event = completion(f.target); + await f.events.append(event); + await f.registrar.onEventPersisted(event, "runtime-a"); + expect(f.authorize).toHaveBeenCalledWith(event, "runtime-a"); + expect(f.scan).toHaveBeenCalledWith(f.target); + expect(f.watch).toHaveBeenCalledWith(f.target); + const before = await f.preferences.createdAgents(); + await f.registrar.close(); + await new CreatedAgentRegistration(f.options).recover(["session-a"]); + expect(await f.preferences.createdAgents()).toEqual(before); + }); + it("repairs an already-created sibling using recorded completion evidence", async () => { + const f = await fixture(); + await f.events.append(completion(f.target)); + await f.registrar.recover(["session-a"]); + expect(await f.preferences.createdAgents()).toMatchObject([ + { projectId: f.projectId, path: f.target }, + ]); + }); + + it("registers and recovers native Codex relative scaffold completions exactly once", async () => { + const f = await fixture(); + const event = codexCompletion("../reviewer"); + await f.events.append(event); + await f.registrar.onEventPersisted(event, "runtime-codex"); + expect(await f.preferences.createdAgents()).toMatchObject([ + { projectId: f.projectId, createdBySessionId: "session-a", path: f.target }, + ]); + expect(f.scan).toHaveBeenCalledWith(f.target); + expect(f.watch).toHaveBeenCalledWith(f.target); + const before = await f.preferences.createdAgents(); + await f.registrar.close(); + const restarted = new CreatedAgentRegistration(f.options); + await restarted.recover(["session-a"]); + expect(await f.preferences.createdAgents()).toEqual(before); + }); + it("rejects a relative Codex target that does not match its exact requested directory", async () => { + const f = await fixture(); + const event = codexCompletion("../reviewer"); + event.payload.toolInput = JSON.stringify({ dir: "." }); + await f.registrar.onEventPersisted(event, "runtime-codex"); + expect(await f.preferences.createdAgents()).toEqual([]); + expect(f.scan).not.toHaveBeenCalled(); + }); + + it("does not persist malformed marker metadata into the membership store", async () => { + const f = await fixture(); + await fs.writeFile( + path.join(f.target, "sapiom.json"), + JSON.stringify({ name: {}, definitionId: "bad-id" }), + ); + await f.registrar.onEventPersisted(completion(f.target), "runtime-a"); + expect(await f.preferences.createdAgents()).toMatchObject([ + { name: "reviewer", definitionId: null }, + ]); + const restarted = new StudioWorkspacePreferenceStore( + path.join(path.dirname(f.target), "prefs.json"), + ); + expect(await restarted.createdAgents()).toHaveLength(1); + }); + it.each([ + "foreign-project", + "stale-runtime", + "principal-changed", + "missing-marker", + "mismatched-result", + ])("rejects %s without discovery or ownership writes", async (reason) => { + const f = await fixture(); + const event = completion(f.target); + if (reason === "foreign-project") + f.projectForPath.mockResolvedValue("another-project"); + if (reason === "stale-runtime") f.authorize.mockResolvedValue(null); + if (reason === "principal-changed") + f.authorize + .mockResolvedValueOnce({ projectId: f.projectId, cwd: f.cwd }) + .mockResolvedValue(null); + if (reason === "missing-marker") + await fs.unlink(path.join(f.target, "sapiom.json")); + if (reason === "mismatched-result") + event.payload.toolInput = JSON.stringify({ dir: f.cwd }); + await f.registrar.onEventPersisted(event, "runtime-a"); + expect(await f.preferences.createdAgents()).toEqual([]); + expect(f.scan).not.toHaveBeenCalled(); + expect(f.watch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/harness/src/core/created-agent-registration.ts b/packages/harness/src/core/created-agent-registration.ts new file mode 100644 index 000000000..5c3f136f3 --- /dev/null +++ b/packages/harness/src/core/created-agent-registration.ts @@ -0,0 +1,174 @@ +import { realpath } from "node:fs/promises"; +import { basename, resolve } from "node:path"; + +import type { AnalyticsEvent } from "../shared/types.js"; +import { inspectAgentProjectMarker } from "./agent-project-discovery.js"; +import type { EventStore } from "./collector/store.js"; +import type { StudioWorkspacePreferenceStore } from "./studio-workspace-preferences.js"; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function json(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +/** Recognize a completed scaffold, not a request, log line, or failed result. */ +export function scaffoldCompletion( + event: AnalyticsEvent, +): { dir: string; targetDir: string } | null { + if (event.type !== "tool.call") return null; + const { toolName, toolInput, toolResponseSummary } = event.payload; + if ( + typeof toolName !== "string" || + !/^(?:mcp__[a-zA-Z0-9_-]+__)?sapiom_dev_agents_scaffold$/.test(toolName) + ) + return null; + const input = json(toolInput); + if (!record(input) || typeof input.dir !== "string" || !input.dir.trim()) + return null; + // Codex stores native MCP results behind a timing header. Only unwrap that + // exact transport shape; arbitrary success prose is not completion evidence. + const codexOutput = event.harness === "codex" && typeof toolResponseSummary === "string" + ? /^Wall time: [0-9]+(?:\.[0-9]+)? seconds\r?\nOutput:\r?\n([\s\S]+)$/.exec(toolResponseSummary)?.[1] + : undefined; + let output = json(codexOutput ?? toolResponseSummary); + // Claude's hook carries the MCP content array. Codex can retain the envelope + // (including isError) or the structured JSON result. + if (record(output) && (output.isError === true || output.error != null)) + return null; + if (record(output) && Array.isArray(output.content)) output = output.content; + if (Array.isArray(output)) { + if (output.length !== 1 || !record(output[0]) || output[0].type !== "text") + return null; + output = json(output[0].text); + } + if ( + !record(output) || + output.isError === true || + output.error != null || + typeof output.targetDir !== "string" || + !output.targetDir.trim() || + typeof output.projectName !== "string" || + !output.projectName || + typeof output.dependenciesInstalled !== "boolean" || + typeof output.gitInitialized !== "boolean" + ) + return null; + return { dir: input.dir, targetDir: output.targetDir }; +} + +interface Creator { + projectId: string; + cwd: string; +} + +export interface CreatedAgentRegistrationOptions { + preferences: StudioWorkspacePreferenceStore; + events: EventStore; + /** Revalidate the server-owned session/principal, and live runtime if given. */ + authorize: ( + event: AnalyticsEvent, + runtimeEpoch?: string, + ) => Promise; + /** Null means unclaimed; a foreign project must never be absorbed. */ + projectForPath: (path: string) => Promise; + scan: (path: string) => Promise; + watch: (path: string) => void; +} + +/** Successful creation -> durable ownership -> exact scan -> live observation. */ +export class CreatedAgentRegistration { + private queue: Promise = Promise.resolve(); + private closed = false; + private observed = new Set(); + + constructor(private readonly options: CreatedAgentRegistrationOptions) {} + + onEventPersisted( + event: AnalyticsEvent, + runtimeEpoch?: string, + ): Promise { + const completion = scaffoldCompletion(event); + if (this.closed || !completion) return Promise.resolve(); + const result = this.queue.then(async () => { + if (this.closed) return; + const creator = await this.options.authorize(event, runtimeEpoch); + if (!creator) return; + let target: string; + try { + // Fresh realpath (not the graph cache): aliases must match, and a + // historical completion is not proof that a folder still exists. + // The native scaffold can echo a relative targetDir. Resolve both + // claimed paths against the same authenticated creator working directory. + target = await realpath(resolve(creator.cwd, completion.targetDir)); + if (target !== (await realpath(resolve(creator.cwd, completion.dir)))) + return; + } catch { + return; + } + const inspection = await inspectAgentProjectMarker(target); + if (inspection.status !== "valid") return; + const owner = await this.options.projectForPath(target); + if (owner && owner !== creator.projectId) return; + // Filesystem/catalog checks yielded: logout, resume, or close can race. + const current = await this.options.authorize(event, runtimeEpoch); + if ( + this.closed || + current?.projectId !== creator.projectId || + current.cwd !== creator.cwd + ) + return; + const registered = await this.options.preferences.registerCreatedAgent( + creator.projectId, + event.harnessSessionId, + { + path: target, + name: + typeof inspection.marker.name === "string" && inspection.marker.name + ? inspection.marker.name + : basename(target), + definitionId: Number.isSafeInteger(inspection.marker.definitionId) + ? inspection.marker.definitionId! + : null, + }, + ); + if (!registered || this.closed) return; + this.options.watch(target); + await this.options.scan(target); + this.observed.add(target); + }); + this.queue = result.catch(() => {}); + return result; + } + + /** Backfill only recorded successful creations, never infer from siblings. */ + async recover(sessionIds: readonly string[]): Promise { + for await (const event of this.options.events.read({ + harnessSessionId: sessionIds, + types: ["tool.call"], + })) { + await this.onEventPersisted(event); + } + // Membership outlives both analytics retention and its creating session. + for (const binding of await this.options.preferences.createdAgents()) { + if (this.closed) return; + if (this.observed.has(binding.path)) continue; + const owner = await this.options.projectForPath(binding.path); + if (owner && owner !== binding.projectId) continue; + this.options.watch(binding.path); + await this.options.scan(binding.path); + } + } + + async close(): Promise { + this.closed = true; + await this.queue; + } +} diff --git a/packages/harness/src/core/inject/claude-settings.test.ts b/packages/harness/src/core/inject/claude-settings.test.ts index 1d1513570..1bf43728d 100644 --- a/packages/harness/src/core/inject/claude-settings.test.ts +++ b/packages/harness/src/core/inject/claude-settings.test.ts @@ -63,13 +63,13 @@ describe("generateClaudeSettings", () => { } }); - it("shows planner onboarding only for a fresh SessionStart hook", async () => { + it("shows a configured startup message only for a fresh SessionStart hook", async () => { const message = [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet.", + "Agent Studio session", + "Review the current project context before beginning.", ].join("\n"); const { emitScriptPath } = await generateClaudeSettings({ - harnessSessionId: "planner-session", + harnessSessionId: "project-session", generatedRoot: tmpDir, sessionStartSystemMessage: message, }); diff --git a/packages/harness/src/core/inject/system-prompt.ts b/packages/harness/src/core/inject/system-prompt.ts index 6d1dc7011..261b6aa90 100644 --- a/packages/harness/src/core/inject/system-prompt.ts +++ b/packages/harness/src/core/inject/system-prompt.ts @@ -9,7 +9,7 @@ import * as path from "node:path"; import { HARNESS_PATHS } from "../../shared/types.js"; import { expandHome } from "../paths.js"; -import { DEFAULT_SYSTEM_PROMPT } from "../../profiles/default.js"; +import { DEFAULT_SYSTEM_PROMPT, resolveKnownSystemPrompt } from "../../profiles/default.js"; export interface GenerateSystemPromptFileOptions { /** Root directory generated configs live under. Defaults to @@ -37,7 +37,7 @@ export async function generateSystemPromptFile( const dir = path.join(root, harnessSessionId); await fs.mkdir(dir, { recursive: true }); - const base = options.prompt ?? DEFAULT_SYSTEM_PROMPT; + const base = resolveKnownSystemPrompt(options.prompt ?? DEFAULT_SYSTEM_PROMPT); const appendix = options.appendix?.trim(); const filePath = path.join(dir, "system-prompt.txt"); await fs.writeFile(filePath, appendix ? `${base}\n\n${appendix}\n` : base, "utf8"); diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index 2f2d26fa7..ec667b2ae 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -2918,6 +2918,28 @@ describe("SessionManager", () => { } }); + it("prepares files before handing the first request to the CLI and never replays it on resume", async () => { + const prepared = deferred(); + const buildLaunchOpts = vi.fn(async (_id: string, _req: CreateSessionRequest) => { await prepared.promise; return {}; }); + const { manager, adapter, spawns } = makeManager({ buildLaunchOpts }); + const initialPrompt = "Build my ticket triage agent."; + const creating = manager.create({ cwd: "/tmp/proj", harness: "claude-code", initialPrompt }); + await vi.waitFor(() => expect(buildLaunchOpts).toHaveBeenCalledOnce()); + expect(adapter.launch).not.toHaveBeenCalled(); + prepared.resolve(); + const session = await creating; + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(adapter.launch).toHaveBeenCalledWith(expect.objectContaining({ initialPrompt })); + expect(spawns[0]!.pty.write).not.toHaveBeenCalled(); + expect(await readFile(sessionsPath, "utf8")).not.toContain(initialPrompt); + await manager.setAgentSessionId(session.id, "native-first-task"); + spawns[0]!.emitExit(0); + await manager.flush(); + await manager.resume(session.id); + expect(adapter.resume).toHaveBeenCalledWith("native-first-task", expect.not.objectContaining({ initialPrompt })); + expect(buildLaunchOpts.mock.calls[1]?.[1]).not.toHaveProperty("initialPrompt"); + }); + it("awaits an async buildLaunchOpts and merges its result into launch opts", async () => { const buildLaunchOpts = vi.fn(async (harnessSessionId: string) => { await new Promise((resolve) => setTimeout(resolve, 5)); diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index c6643075e..b43e7398f 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -3459,6 +3459,7 @@ export class SessionManager { opts = { harnessSessionId: id, cwd: req.cwd, + ...(req.initialPrompt ? { initialPrompt: req.initialPrompt } : {}), ...(await (launchContext ? this.buildLaunchOpts(id, req, launchContext) : this.buildLaunchOpts(id, req))), diff --git a/packages/harness/src/core/studio-workspace-preferences.test.ts b/packages/harness/src/core/studio-workspace-preferences.test.ts index 84b9fa8be..216c88381 100644 --- a/packages/harness/src/core/studio-workspace-preferences.test.ts +++ b/packages/harness/src/core/studio-workspace-preferences.test.ts @@ -38,6 +38,131 @@ describe("StudioWorkspacePreferenceStore", () => { }; } + it("keeps a scaffolded sibling in its creating project across scans and restart", async () => { + const value = await fixture(); + const projectRoot = path.join(value.root, "project"); + const sibling = { + name: "Reviewer", + path: path.join(value.root, "reviewer"), + definitionId: null, + }; + const store = new StudioWorkspacePreferenceStore(value.file); + const created = await store.registerCreatedAgent( + value.projectId, + "session-a", + sibling, + ); + expect(created).toBe(true); + const first = await store.current( + "user", + value.projectId, + [projectRoot], + [sibling], + true, + ); + expect(first.agents).toHaveLength(1); + await store.put( + "user", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + }, + [projectRoot], + [sibling], + true, + ); + // A complete scan of the project directory says nothing about its sibling. + expect( + await store.current("user", value.projectId, [projectRoot], [], true), + ).toMatchObject({ repaired: false }); + expect( + await store.put( + "user", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + }, + [projectRoot], + [], + true, + ), + ).toMatchObject({ repaired: false }); + const restarted = new StudioWorkspacePreferenceStore(value.file); + await restarted.registerCreatedAgent(value.projectId, "session-a", sibling); + const restored = await restarted.current( + "user", + value.projectId, + [projectRoot], + [sibling], + true, + ); + expect(restored.agents).toEqual(first.agents); + expect(restored.selection).toMatchObject({ + kind: "agent", + agentId: first.agents[0]!.agentId, + }); + expect(JSON.stringify(restored)).not.toContain(sibling.path); + expect(await restarted.createdAgents()).toMatchObject([ + { + projectId: value.projectId, + path: sibling.path, + createdBySessionId: "session-a", + }, + ]); + }); + + it("does not let another project claim a created agent, even through a containing root", async () => { + const value = await fixture(); + const foreign = "project_00000000-0000-4000-8000-000000000002"; + const store = new StudioWorkspacePreferenceStore(value.file); + await store.registerCreatedAgent( + value.projectId, + "session-a", + value.workflows[0]!, + ); + expect( + await store.registerCreatedAgent( + foreign, + "session-b", + value.workflows[0]!, + ), + ).toBe(false); + expect( + ( + await store.current( + "user", + foreign, + [value.root], + value.workflows, + true, + ) + ).agents, + ).toEqual([]); + }); + + it("does not take over an agent already assigned to another project", async () => { + const value = await fixture(); + const store = new StudioWorkspacePreferenceStore(value.file); + await store.current( + "user", + value.projectId, + [value.root], + value.workflows, + true, + ); + expect( + await store.registerCreatedAgent( + "project_00000000-0000-4000-8000-000000000002", + "session-b", + value.workflows[0]!, + ), + ).toBe(false); + }); + it("defaults to map and restores an opaque agent selection after restart", async () => { const value = await fixture(); const projectRoot = path.join(value.root, "project"); diff --git a/packages/harness/src/core/studio-workspace-preferences.ts b/packages/harness/src/core/studio-workspace-preferences.ts index 7b063ae9a..7c6116741 100644 --- a/packages/harness/src/core/studio-workspace-preferences.ts +++ b/packages/harness/src/core/studio-workspace-preferences.ts @@ -17,6 +17,8 @@ interface PrivateAgentBinding extends StudioWorkspaceAgentSummary { projectId: StudioProjectId; /** Private reconciliation evidence. Never returned by this store. */ path: string; + /** Successful scaffold ownership, independent of physical root containment. */ + createdBySessionId?: string; updatedAt: string; } @@ -124,6 +126,9 @@ function parseState(value: unknown): PersistedPreferences { !candidate.name || typeof candidate.path !== "string" || !candidate.path || + (candidate.createdBySessionId !== undefined && + (typeof candidate.createdBySessionId !== "string" || + !candidate.createdBySessionId)) || (candidate.definitionId !== null && !Number.isSafeInteger(candidate.definitionId)) || !validTimestamp(candidate.updatedAt) @@ -207,11 +212,18 @@ export class StudioWorkspacePreferenceStore { agentBindings: state.agentBindings.map((binding) => ({ ...binding })), }; let changed = false; - const eligible = workflows.filter((workflow) => - roots.some( - (root) => workspaceRelativeLocalKey(root, workflow.path) !== null, - ), - ); + const eligible = workflows.filter((workflow) => { + const created = state.agentBindings.find( + (binding) => + binding.path === workflow.path && binding.createdBySessionId, + ); + // Explicit creation ownership wins over a broader physical parent root. + return created + ? created.projectId === projectId + : roots.some( + (root) => workspaceRelativeLocalKey(root, workflow.path) !== null, + ); + }); const agents = eligible.map((workflow) => { let binding = next.agentBindings.find( (candidate) => @@ -257,6 +269,8 @@ export class StudioWorkspacePreferenceStore { const retained = next.agentBindings.filter( (binding) => binding.projectId !== projectId || + // A scan of the project root cannot disprove a sibling's existence. + Boolean(binding.createdBySessionId) || activeAgentIds.has(binding.agentId), ); if (retained.length !== next.agentBindings.length) { @@ -291,7 +305,17 @@ export class StudioWorkspacePreferenceStore { !requested || requested.kind !== "agent" || reconciled.agents.some((agent) => agent.agentId === requested.agentId); - const repaired = Boolean(preference && !valid && scanComplete); + const createdSelection = + requested?.kind === "agent" && + reconciled.state.agentBindings.some( + (binding) => + binding.projectId === projectId && + binding.agentId === requested.agentId && + binding.createdBySessionId, + ); + const repaired = Boolean( + preference && !valid && scanComplete && !createdSelection, + ); const selection: StudioWorkspaceSelection = preference && valid ? preference.selection @@ -312,6 +336,49 @@ export class StudioWorkspacePreferenceStore { }); } + /** Server-only: called after authenticated, successful scaffold evidence. */ + async registerCreatedAgent( + projectId: StudioProjectId, + sessionId: string, + workflow: SelectableWorkflow, + ): Promise { + return this.enqueue(async () => { + const state = await this.load(); + const existing = state.agentBindings.filter( + (binding) => binding.path === workflow.path, + ); + if (existing.some((binding) => binding.projectId !== projectId)) + return false; + if (existing.some((binding) => binding.createdBySessionId)) return true; + const binding: PrivateAgentBinding = { + ...workflow, + projectId, + agentId: existing[0]?.agentId ?? `agent_${randomUUID()}`, + createdBySessionId: sessionId, + updatedAt: this.now().toISOString(), + }; + await this.persist({ + ...state, + agentBindings: [ + ...state.agentBindings.filter( + (candidate) => candidate.path !== workflow.path, + ), + binding, + ], + }); + return true; + }); + } + + /** Private exact paths to observe on restart; never added as project roots. */ + async createdAgents(): Promise> { + return this.enqueue(async () => + (await this.load()).agentBindings + .filter((binding) => binding.createdBySessionId) + .map((binding) => ({ ...binding })), + ); + } + async put( userId: string, projectId: StudioProjectId, @@ -351,6 +418,14 @@ export class StudioWorkspacePreferenceStore { binding.projectId === projectId && binding.agentId === normalized.agentId, ); + const createdAgent = + normalized.kind === "agent" && + reconciled.state.agentBindings.some( + (binding) => + binding.projectId === projectId && + binding.agentId === normalized.agentId && + binding.createdBySessionId, + ); // A degraded scan cannot disprove a server-issued opaque id. Accept a // binding the private store still knows, and leave an unknown request's // previous durable preference untouched until a complete scan can judge @@ -359,9 +434,11 @@ export class StudioWorkspacePreferenceStore { sameProject && (normalized.kind === "agent-map" || visibleAgent || - (!scanComplete && privatelyKnownAgent)); + ((!scanComplete || createdAgent) && privatelyKnownAgent)); const absenceUnproven = - sameProject && normalized.kind === "agent" && !scanComplete; + sameProject && + normalized.kind === "agent" && + (!scanComplete || createdAgent); const selection: StudioWorkspaceSelection = accepted ? normalized : absenceUnproven @@ -445,6 +522,9 @@ export class StudioWorkspacePreferenceStore { changed = true; return { ...binding, + // An explicit disk move is a new ownership decision. Reconcile at + // the destination; do not replay the old creation path into it. + createdBySessionId: undefined, path: relative === "" ? target : path.join(target, relative), updatedAt: this.now().toISOString(), }; diff --git a/packages/harness/src/core/subsession-coordinator.test.ts b/packages/harness/src/core/subsession-coordinator.test.ts index 05d6dca48..739f72049 100644 --- a/packages/harness/src/core/subsession-coordinator.test.ts +++ b/packages/harness/src/core/subsession-coordinator.test.ts @@ -185,6 +185,7 @@ describe("SubsessionCoordinator", () => { storeOptions: NonNullable< ConstructorParameters[1] > = {}, + harness: "claude-code" | "codex" = "claude-code", ) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "subsession-service-")); roots.push(root); @@ -198,10 +199,10 @@ describe("SubsessionCoordinator", () => { const closeStore: { current?: SubsessionCoordinatorStore } = {}; const manager = new SessionManager({ adapters: { - "claude-code": adapter( + [harness]: { ...adapter( resumable, childIdentityState ? "transcript-tail" : "hooks", - ), + ), id: harness }, }, ingestUrl: "http://127.0.0.1:4100/ingest", ingestCredentials: new IngestCredentialRegistry(), @@ -220,7 +221,7 @@ describe("SubsessionCoordinator", () => { managers.push(manager); await manager.init(); await manager.create( - { cwd: root, harness: "claude-code" }, + { cwd: root, harness }, { agentMapIdentity: (sessionId) => ({ ...identity, sessionId }), }, @@ -467,6 +468,27 @@ describe("SubsessionCoordinator", () => { expect(writeSubsessionBindingRegistry).toHaveBeenCalledTimes(5); }); + it("submits a fresh owned Codex kickoff before its first rollout exists", async () => { + const { manager, caller, coordinator, spawned, unsubscribe } = await fixture(false, "ready", {}, {}, "codex"); + unsubscribe(); + const stop = manager.onStatusChange((session, context) => { + if (session.id !== caller.sessionId && session.status === "running" && !session.ready && context.runtimeEpoch) + manager.setReady(session.id, context.runtimeEpoch); + }); + try { + const first = await coordinator.execute(caller, request); + expect(first.results[0]).toMatchObject({ outcome: "created", sessionState: "ready", kickoffState: "submitted-unacknowledged" }); + const childId = first.results[0]!.sessionId!; + expect(manager.get(childId)?.agentSessionId).toBeNull(); + const writes = spawned[1]!.writes.join(""); + expect(writes).toMatch(//u); + expect(writes).toContain("sapiom-project-delegation"); + await coordinator.execute(caller, request); + expect(spawned).toHaveLength(2); + expect(spawned[1]!.writes.join("")).toBe(writes); + } finally { stop(); } + }); + it("bounds a not-ready batch and resumes the same durable children only on an explicit retry", async () => { const { manager, caller, store, newCoordinator, spawnPty, spawned, unsubscribe } = await fixture(); unsubscribe(); diff --git a/packages/harness/src/core/subsession-coordinator.ts b/packages/harness/src/core/subsession-coordinator.ts index 6048a9f8b..51843bbfe 100644 --- a/packages/harness/src/core/subsession-coordinator.ts +++ b/packages/harness/src/core/subsession-coordinator.ts @@ -76,6 +76,8 @@ export class SubsessionCoordinatorError extends Error { } } +import { codexRuntimeMarker } from "./collector/codex-runtime-marker.js"; + export interface SubsessionCoordinatorOptions { store: SubsessionCoordinatorStore; sessionManager: SessionManager; @@ -1030,7 +1032,16 @@ export class SubsessionCoordinator { if (binding.sessionState === "awaiting-ready") { const ready = await this.waitForReady(binding.sessionId, runtimeToken, waitDeadline); if (!ready) throw new SessionNotReadyError(binding.sessionId); - await this.waitForAdapterIdentity(binding.sessionId, runtimeToken, waitDeadline); + const session = this.options.sessionManager.get(binding.sessionId); + const identityState = this.options.sessionManager.getAdapterIdentityState(binding.sessionId, runtimeToken); + // Fresh Codex creates its rollout only after the first turn. The exact + // private binding and runtime own this PTY; its marked first prompt lets + // the broker prove transcript ownership after submission. + const firstOwnedCodexTurn = binding.harness === "codex" && session?.agentSessionId === null && + binding.contextEpoch === 1 && currentDelivery(binding).state === "pending" && + identityState !== "ambiguous" && binding.runtime?.runtimeToken === runtimeToken; + if (!firstOwnedCodexTurn) + await this.waitForAdapterIdentity(binding.sessionId, runtimeToken, waitDeadline); binding = await this.options.store.transitionSession( identity, binding.bindingId, @@ -1214,6 +1225,8 @@ export class SubsessionCoordinator { ].join("\n\n"); } return [ + ...(binding.harness === "codex" && binding.runtime + ? [codexRuntimeMarker(binding.runtime.runtimeToken)] : []), "You are an ordinary writable project session delegated by another project session.", `Outcome: ${binding.outcome}`, ...(binding.kickoffContext diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 39180dd40..507731bcc 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -21,15 +21,6 @@ export type { PlanNodeKind, PlanRelationship, PlanRelationshipId, - ProposalActor, - ProjectAgentSession, - ProjectBootstrapInputReceipt, - ProjectBootstrapMetadata, - ProjectBootstrapState, - ProposalOperationId, - RelationshipChanges, - RelationshipKind, - StudioProjectId, AgentMapVersion, AgentMapVersionId, AgentMapVersionRef, @@ -40,71 +31,16 @@ export type { ProjectVersionChangeKind, RecordDigest, RoleNeutralMapOperationRecord, + ProjectAgentSession, + ProjectBootstrapInputReceipt, + ProjectBootstrapMetadata, + ProjectBootstrapState, + ProposalActor, + ProposalOperationId, + RelationshipChanges, + RelationshipKind, + StudioProjectId, } from "./shared/agent-map.js"; -export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; -export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; -export { - HarnessError, - UnknownSessionError, - SessionNotReadyError, - SessionNotResumeableError, - SessionAlreadyLiveError, - SubsessionBindingMismatchError, - SubsessionFreshRestartForbiddenError, - AdapterNotFoundError, - ExternalHarnessError, - SpawnTargetError, -} from "./core/errors.js"; -export { - listHarnessAdapters, - getHarnessAdapter, - createHarnessAdapterRegistry, - UnknownHarnessAdapterError, -} from "./core/adapters/registry.js"; -export type { - HarnessAdapterInfo, - HarnessAdapterId, - HarnessAdapterMode, - EmbeddedHarnessAdapterInfo, - ExternalHarnessAdapterInfo, -} from "./core/adapters/adapter.js"; - -// Embedding surface (SAP: harness-desktop) — lets a second host (the Electron -// app) reuse the exact server + setup flow the CLI (`bin.ts`) runs, instead of -// forking it. `ensureConsent`/`printDoctorReport` are intentionally NOT exported: -// they are TTY-shaped, and a native host supplies `telemetryOptIn`/`consentSource` -// to `startServer` directly — which is why `saveSettings` is exported too: a -// native host that prompts for consent itself must persist the answer the way -// `ensureConsent` does, or the settings file (the source of truth for the UI's -// analytics chip and for the next launch) never learns about it. -export { startServer } from "./server/index.js"; -export type { HarnessServer, HarnessServerOptions } from "./server/index.js"; -export { - runDoctor, - pickDefaultHarness, - CLAUDE_INSTALL_COMMAND, - CODEX_INSTALL_COMMAND, -} from "./cli/doctor.js"; -export type { DoctorReport } from "./cli/doctor.js"; -export { ensureAuthenticated } from "./cli/auth.js"; -export type { HarnessIdentity } from "./cli/auth.js"; -export { getOrCreateMachineId } from "./cli/machine-id.js"; -export { ensureSpawnHelperExecutable } from "./core/session-manager.js"; -// Exported so a host can spawn a pty the same way the harness does — Windows -// cannot launch a bare command name or a .cmd shim directly (see the module). -export { resolveSpawnTarget } from "./core/spawn-target.js"; -export type { SpawnTarget } from "./core/spawn-target.js"; -// Lets a host point the claude-code adapter at a different binary. Used by the -// desktop app's --smoke mode to create a REAL session against a stub agent, so -// per-OS session coverage doesn't require Claude Code installed on a CI runner. -export { createClaudeCodeAdapter } from "./core/adapters/claude-code.js"; -export { - loadSettings, - saveSettings, - recordRecentDir, - hasStoredSettings, -} from "./cli/settings.js"; - export { canonicalJson, canonicalizeAgentMapGraph, @@ -121,6 +57,46 @@ export { agentMapVersionRefsEqual, projectBuildPlanVersionRefsEqual, } from "./shared/build-plan.js"; +export { + PROJECT_SUBSESSION_CLAIM_TTL_MS, + PROJECT_SUBSESSION_DELEGATION_LIMIT, + PROJECT_SUBSESSION_KEY_BYTES, + PROJECT_SUBSESSION_KICKOFF_CONTEXT_BYTES, + PROJECT_SUBSESSION_OUTCOME_BYTES, + PROJECT_SUBSESSION_REQUEST_BYTES, + PROJECT_SUBSESSION_SCHEMA_VERSION, + SUBSESSION_COORDINATOR_STORAGE_SCHEMA_VERSION, +} from "./shared/subsession-delegation.js"; +export type { + CanonicalDelegationBindingDigest, + CanonicalDelegationRequestDigest, + DelegatedContextState, + DelegatedKickoffState, + DelegatedSessionState, + DelegationError, + DelegationErrorCode, + DelegationFocusRef, + DelegationItemOutcome, + DelegationItemResult, + DelegationRecovery, + ProjectSubsessionDelegation, + ProjectSubsessionRequest, + ProjectSubsessionResult, + SubsessionBindingId, + SubsessionBindingRecord, + SubsessionClaim, + SubsessionContextDigest, + SubsessionKickoffDelivery, + SubsessionProjectionDigest, + SubsessionRuntimeBinding, +} from "./shared/subsession-delegation.js"; +export { + computeCanonicalDelegationBindingDigest, + computeCanonicalDelegationRequestDigest, + computeSubsessionContextDigest, + parseProjectSubsessionRequest, + SubsessionDelegationValidationError, +} from "./shared/subsession-delegation-codec.js"; export { canonicalWorkstreamScopes, canonicalizeAgentBriefFocusScope, @@ -205,7 +181,6 @@ export { computeBuildPlanRequestDigest, computeBuildPlanSemanticDigest, } from "./core/build-plan-canonicalization.js"; - export { AGENT_BRIEF_COMPILER_DIAGNOSTIC_LIMIT, DeterministicAgentBriefCompiler, @@ -236,49 +211,70 @@ export type { FocusedSessionContextProjection, FocusedSessionContextResult, } from "./core/focused-session-context.js"; - -export { - PROJECT_SUBSESSION_CLAIM_TTL_MS, - PROJECT_SUBSESSION_DELEGATION_LIMIT, - PROJECT_SUBSESSION_KEY_BYTES, - PROJECT_SUBSESSION_KICKOFF_CONTEXT_BYTES, - PROJECT_SUBSESSION_OUTCOME_BYTES, - PROJECT_SUBSESSION_REQUEST_BYTES, - PROJECT_SUBSESSION_SCHEMA_VERSION, - SUBSESSION_COORDINATOR_STORAGE_SCHEMA_VERSION, -} from "./shared/subsession-delegation.js"; -export type { - CanonicalDelegationBindingDigest, - CanonicalDelegationRequestDigest, - DelegatedContextState, - DelegatedKickoffState, - DelegatedSessionState, - DelegationError, - DelegationErrorCode, - DelegationFocusRef, - DelegationItemOutcome, - DelegationItemResult, - DelegationRecovery, - ProjectSubsessionDelegation, - ProjectSubsessionRequest, - ProjectSubsessionResult, - SubsessionBindingId, - SubsessionBindingRecord, - SubsessionClaim, - SubsessionContextDigest, - SubsessionKickoffDelivery, - SubsessionProjectionDigest, - SubsessionRuntimeBinding, -} from "./shared/subsession-delegation.js"; -export { - computeCanonicalDelegationBindingDigest, - computeCanonicalDelegationRequestDigest, - computeSubsessionContextDigest, - parseProjectSubsessionRequest, - SubsessionDelegationValidationError, -} from "./shared/subsession-delegation-codec.js"; - export { PROJECT_AGENT_PROMPT_APPENDIX, projectAgentPromptAppendix, } from "./profiles/project-agent.js"; +export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; +export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; +export { + HarnessError, + UnknownSessionError, + SessionNotReadyError, + SessionNotResumeableError, + SessionAlreadyLiveError, + SubsessionBindingMismatchError, + SubsessionFreshRestartForbiddenError, + AdapterNotFoundError, + ExternalHarnessError, + SpawnTargetError, +} from "./core/errors.js"; +export { + listHarnessAdapters, + getHarnessAdapter, + createHarnessAdapterRegistry, + UnknownHarnessAdapterError, +} from "./core/adapters/registry.js"; +export type { + HarnessAdapterInfo, + HarnessAdapterId, + HarnessAdapterMode, + EmbeddedHarnessAdapterInfo, + ExternalHarnessAdapterInfo, +} from "./core/adapters/adapter.js"; + +// Embedding surface (SAP: harness-desktop) — lets a second host (the Electron +// app) reuse the exact server + setup flow the CLI (`bin.ts`) runs, instead of +// forking it. `ensureConsent`/`printDoctorReport` are intentionally NOT exported: +// they are TTY-shaped, and a native host supplies `telemetryOptIn`/`consentSource` +// to `startServer` directly — which is why `saveSettings` is exported too: a +// native host that prompts for consent itself must persist the answer the way +// `ensureConsent` does, or the settings file (the source of truth for the UI's +// analytics chip and for the next launch) never learns about it. +export { startServer } from "./server/index.js"; +export type { HarnessServer, HarnessServerOptions } from "./server/index.js"; +export { + runDoctor, + pickDefaultHarness, + CLAUDE_INSTALL_COMMAND, + CODEX_INSTALL_COMMAND, +} from "./cli/doctor.js"; +export type { DoctorReport } from "./cli/doctor.js"; +export { ensureAuthenticated } from "./cli/auth.js"; +export type { HarnessIdentity } from "./cli/auth.js"; +export { getOrCreateMachineId } from "./cli/machine-id.js"; +export { ensureSpawnHelperExecutable } from "./core/session-manager.js"; +// Exported so a host can spawn a pty the same way the harness does — Windows +// cannot launch a bare command name or a .cmd shim directly (see the module). +export { resolveSpawnTarget } from "./core/spawn-target.js"; +export type { SpawnTarget } from "./core/spawn-target.js"; +// Lets a host point the claude-code adapter at a different binary. Used by the +// desktop app's --smoke mode to create a REAL session against a stub agent, so +// per-OS session coverage doesn't require Claude Code installed on a CI runner. +export { createClaudeCodeAdapter } from "./core/adapters/claude-code.js"; +export { + loadSettings, + saveSettings, + recordRecentDir, + hasStoredSettings, +} from "./cli/settings.js"; diff --git a/packages/harness/src/profiles/default.test.ts b/packages/harness/src/profiles/default.test.ts index 3b0205654..b3ac9d378 100644 --- a/packages/harness/src/profiles/default.test.ts +++ b/packages/harness/src/profiles/default.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { describe, it, expect } from "vitest"; -import { DEFAULT_SYSTEM_PROMPT } from "./default.js"; +import { DEFAULT_SYSTEM_PROMPT, resolveKnownSystemPrompt } from "./default.js"; const sha256 = (content: string) => createHash("sha256").update(content, "utf8").digest("hex"); @@ -11,7 +12,9 @@ const sha256 = (content: string) => * the copy it serves, so the two move together — see the drift-guard test below. */ const PINNED_PROMPT_DIGEST = - "f9128ff6afed47242b7bc7946b2e1dab20627171371191cdd2c45537198ce8ed"; + "e9453ef4262205c54a6d1d4833cdaa963259ef7bd9e035167ca0cf93a1b502b6"; + +const legacy = readFileSync(new URL("./fixtures/legacy-system-prompt.md", import.meta.url), "utf8").trim(); describe("DEFAULT_SYSTEM_PROMPT", () => { it("stays byte-identical to the copy the backend serves (cross-repo pin, SAP-2810)", () => { @@ -37,4 +40,38 @@ describe("DEFAULT_SYSTEM_PROMPT", () => { expect(DEFAULT_SYSTEM_PROMPT).toContain("Local Run, Prod Run, and Deploy"); expect(DEFAULT_SYSTEM_PROMPT).toContain("sapiom_send_feedback"); }); + + it("orients to the project map without delaying a clear first request", () => { + expect(DEFAULT_SYSTEM_PROMPT).toContain("agent-map"); + expect(DEFAULT_SYSTEM_PROMPT).toContain("shared project Agent Map"); + expect(DEFAULT_SYSTEM_PROMPT).toContain("clear task"); + expect(DEFAULT_SYSTEM_PROMPT).not.toContain("two Sapiom MCP servers"); + expect(DEFAULT_SYSTEM_PROMPT).not.toContain("The two MCPs"); + expect(DEFAULT_SYSTEM_PROMPT).not.toContain("then stop"); + }); +}); + +describe("known served-prompt compatibility", () => { + it("pins the one historical revision that is safe to upgrade", () => { + expect(sha256(legacy)).toBe("f9128ff6afed47242b7bc7946b2e1dab20627171371191cdd2c45537198ce8ed"); + expect(resolveKnownSystemPrompt(`\n${legacy}\n`)).toBe(DEFAULT_SYSTEM_PROMPT); + }); + + it("never replaces unknown, custom, or newer remote guidance", () => { + for (const prompt of ["custom profile", DEFAULT_SYSTEM_PROMPT, + `${legacy}\nNew runtime instructions from the backend.`]) { + expect(resolveKnownSystemPrompt(prompt)).toBe(prompt); + } + }); + + it("preserves the legacy runtime, feedback, and workspace guidance verbatim", () => { + for (const [start, end] of [ + ["**Calling LLMs", "**The authoring loop"], + ["**Your current workspace state", "**In your very first reply"], + ]) { + const block = legacy.slice(legacy.indexOf(start), legacy.indexOf(end)).trim(); + expect(block.length).toBeGreaterThan(100); + expect(DEFAULT_SYSTEM_PROMPT).toContain(block); + } + }); }); diff --git a/packages/harness/src/profiles/default.ts b/packages/harness/src/profiles/default.ts index 72f5d0833..1dae853d7 100644 --- a/packages/harness/src/profiles/default.ts +++ b/packages/harness/src/profiles/default.ts @@ -1,17 +1,18 @@ +import { createHash } from "node:crypto"; + /** * Default system prompt, appended to the coding agent's own instructions via * `--append-system-prompt`. Orients a fresh session to the Sapiom-specific * conventions the harness adds on top of a stock coding agent. Written to be - * assertive, not just informative — first-user feedback showed the prompt - * was being injected (confirmed via ps) but behaviorally invisible, so the - * closing line asks for one visible signal that it actually loaded. + * shared by the CLI and desktop hosts. Authoring and runtime guidance is primary; + * orientation should help the user start, never delay a clear first request. */ export const DEFAULT_SYSTEM_PROMPT = ` You are the coding agent running in Agent Studio. This is not a stock coding session — -you have two Sapiom MCP servers pre-wired, and the conventions below are +you have Sapiom MCP servers pre-wired, and the conventions below are active for the whole session. Follow them. -**The two MCPs, and when to use each:** +**The MCPs, and when to use each:** - **sapiom** (remote, HTTP) — the paid capability surface an agent calls at *runtime* from inside a deployed agent's step code (ctx.sapiom.*): repositories, sandboxes, LLM calls (see below), and so on. You don't call @@ -21,6 +22,9 @@ active for the whole session. Follow them. and Prod Run are authenticated cloud operations. Use its sapiom_dev_agents_* tools to author and ship agents, and sapiom_authenticate / sapiom_status if you need to sign in. +- **agent-map** (local, HTTP, in a Studio project) — shared project Agent Map, + build-plan, and writable subsession tools. These support agent delivery; + they do not replace the authoring tools or execute deployed agents. **Calling LLMs from agent code:** one-shot call → \`ctx.sapiom.llm.run\`; a platform-driven multi-turn loop → \`ctx.sapiom.models.run\` (never for a @@ -47,17 +51,20 @@ contents, logs, or secrets. capability spend, while the code's own side effects remain real) → link (associate the project with a hosted agent) → deploy (push, build, go live). Read a project's AGENTS.md before touching its steps — it documents that project's specifics. +Stop at the stage the user requested; a local-only task does not authorize deployment. **Canvas convention:** the canvas pane renders the selected agent's step graph automatically and deterministically — the harness extracts it from the agent's manifest and draws the diagram (nodes, edges, a summary and annotations) server-side: no LLM, no tokens, identical every time. You do NOT author or edit any canvas HTML, and there is nothing to write under -\`.sapiom/canvas/\`. When someone asks to "visualize this agent" or "how -does everything connect", make sure the agent is selected in the workspace +\`.sapiom/canvas/\`. When someone asks to "visualize this agent", make sure +the agent is selected in the workspace rail. The Canvas follows that selection and refreshes automatically when the source changes. Local Run, Prod Run, and Deploy are available in the selected -agent's action bar. +agent's action bar. For how multiple agents, resources, and artifacts connect, +use the shared project Agent Map instead: it is maintained through project +tools, not automatically inferred from source edits. **Your current workspace state:** Agent Studio mirrors what it knows about this workspace at \`.sapiom/harness-context.json\`, relative to your working @@ -71,18 +78,24 @@ ask what agents exist — both fields can change mid-session (a new selection, a newly scanned/connected project), so re-read the file rather than assuming it's still what it was earlier in the conversation. -**In your very first reply this session**, orient the person before you get -to their actual request — briefly, 2-4 sentences total, not a lecture: -1. Acknowledge that you're the coding agent in Agent Studio with these MCPs - available (one line), so they can see this loaded. -2. Say what you can do for them here: inspect the selected agent on its - automatically generated Canvas, start a local agent run against stub - capabilities at no cost, start a production agent run after deployment, - and deploy it live. The exact action-bar controls are Local Run, Prod Run, - and Deploy. -3. Suggest ONE concrete first step, picked from the workspace state file - above: if an agent is bound or listed (e.g. the bundled order-triage - sample project), offer by name to inspect its Canvas or start a Local Run; if none - exists yet, offer to scaffold a new agent project. Phrase it as an - invitation ("want me to…?"), then stop — don't act on it unprompted. +**Your first reply:** if the user supplied a clear task, briefly acknowledge +it and proceed within its scope; do not ask them to repeat or reconfirm it. +If they have not supplied a task, use the workspace state to offer one concrete +next step: scaffold their first agent, or inspect/test an existing agent by name. +Keep orientation to 1-2 relevant sentences: author and test agents here, inspect +the per-agent Canvas or shared project Agent Map, and deploy when requested. +Do not assume a sample project exists or recite every tool. `.trim(); + +/** + * A published backend may still serve this exact older bundled profile. Upgrade + * only that known revision at materialization time: broad text replacement could + * erase newer remote authoring/runtime instructions or a host's custom profile. + * Keep the legacy fixture/digest fixed when the current prompt pin moves. + */ +export function resolveKnownSystemPrompt(prompt: string): string { + const digest = createHash("sha256").update(prompt.trim(), "utf8").digest("hex"); + return digest === "f9128ff6afed47242b7bc7946b2e1dab20627171371191cdd2c45537198ce8ed" + ? DEFAULT_SYSTEM_PROMPT + : prompt; +} diff --git a/packages/harness/src/profiles/fixtures/legacy-system-prompt.md b/packages/harness/src/profiles/fixtures/legacy-system-prompt.md new file mode 100644 index 000000000..682d64176 --- /dev/null +++ b/packages/harness/src/profiles/fixtures/legacy-system-prompt.md @@ -0,0 +1,78 @@ +You are the coding agent running in Agent Studio. This is not a stock coding session — +you have two Sapiom MCP servers pre-wired, and the conventions below are +active for the whole session. Follow them. + +**The two MCPs, and when to use each:** +- **sapiom** (remote, HTTP) — the paid capability surface an agent calls at + *runtime* from inside a deployed agent's step code (ctx.sapiom.*): + repositories, sandboxes, LLM calls (see below), and so on. You don't call + this directly while authoring. +- **sapiom-dev** (local, stdio) — the developer surface for this session. Its + scaffold, check, and Local Run path uses no Sapiom capability spend; Deploy + and Prod Run are authenticated cloud operations. Use its sapiom_dev_agents_* + tools to author and ship agents, and sapiom_authenticate / sapiom_status if + you need to sign in. + +**Calling LLMs from agent code:** one-shot call → `ctx.sapiom.llm.run`; a +platform-driven multi-turn loop → `ctx.sapiom.models.run` (never for a +one-shot — it overthinks); dispatching a deployed agent by slug → +`ctx.sapiom.agents.run`. Structured output = tool-use/schema output — read +the `tool_use` block's input, never string-parse; a plain-text reply reads +only `type === 'text'` blocks. **Omit `model` entirely** — the platform +routes it, and `smart` is already the default, so naming it changes +nothing. Reach for `small`/`medium`/`large` only to choose a class +deliberately. Raw provider ids are never +honored. Results disclose the served class + lane. Debugging a run: the +Run Inspector, or the per-step I/O endpoint documented in the guide. +Guide: https://docs.sapiom.ai/guides/choose-a-call-surface. + +**When something about Sapiom is wrong, send it upstream.** If the user hits a +bug, calls something confusing or broken, or wishes it worked differently, +offer to pass it on — sapiom_send_feedback puts their words in front of the +team. Confirm the wording, send what they actually said, and never include file +contents, logs, or secrets. + +**The authoring loop, in order:** scaffold a new agent project → check +(typecheck + bundle/import + manifest + step-graph validation; no Sapiom account) +→ run_local (your real step code with ctx.sapiom.* calls stubbed; no Sapiom +capability spend, while the code's own side effects remain real) → link (associate the project +with a hosted agent) → deploy (push, build, go live). Read a project's +AGENTS.md before touching its steps — it documents that project's specifics. + +**Canvas convention:** the canvas pane renders the selected agent's step +graph automatically and deterministically — the harness extracts it from the +agent's manifest and draws the diagram (nodes, edges, a summary and +annotations) server-side: no LLM, no tokens, identical every time. You do NOT +author or edit any canvas HTML, and there is nothing to write under +`.sapiom/canvas/`. When someone asks to "visualize this agent" or "how +does everything connect", make sure the agent is selected in the workspace +rail. The Canvas follows that selection and refreshes automatically when the +source changes. Local Run, Prod Run, and Deploy are available in the selected +agent's action bar. + +**Your current workspace state:** Agent Studio mirrors what it knows about +this workspace at `.sapiom/harness-context.json`, relative to your working +directory (`{"boundAgent": {name, path, definitionId} | null, +"agents": [{name, path, definitionId}, ...], "session": {id, cwd, +harness}, "updatedAt": ...}`). `boundAgent` is whichever deployable agent the +person currently has selected in the app, or `null` if none; +`agents` is every agent currently known to this Agent Studio installation, +selected or not. Read it when they say "this agent," ask what they're working on, or +ask what agents exist — both fields can change mid-session (a new +selection, a newly scanned/connected project), so re-read the file rather +than assuming it's still what it was earlier in the conversation. + +**In your very first reply this session**, orient the person before you get +to their actual request — briefly, 2-4 sentences total, not a lecture: +1. Acknowledge that you're the coding agent in Agent Studio with these MCPs + available (one line), so they can see this loaded. +2. Say what you can do for them here: inspect the selected agent on its + automatically generated Canvas, start a local agent run against stub + capabilities at no cost, start a production agent run after deployment, + and deploy it live. The exact action-bar controls are Local Run, Prod Run, + and Deploy. +3. Suggest ONE concrete first step, picked from the workspace state file + above: if an agent is bound or listed (e.g. the bundled order-triage + sample project), offer by name to inspect its Canvas or start a Local Run; if none + exists yet, offer to scaffold a new agent project. Phrase it as an + invitation ("want me to…?"), then stop — don't act on it unprompted. diff --git a/packages/harness/src/profiles/project-agent.test.ts b/packages/harness/src/profiles/project-agent.test.ts index c27f61127..ab8f95cc8 100644 --- a/packages/harness/src/profiles/project-agent.test.ts +++ b/packages/harness/src/profiles/project-agent.test.ts @@ -11,3 +11,46 @@ describe("common writable project prompt", () => { expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain("no role, approval, confirmation, or mode transition"); }); }); + +describe("project-agent operating guidance", () => { + const prompt = PROJECT_AGENT_PROMPT_APPENDIX; + + it("keeps delivery primary and project coordination out of the approval path", () => { + expect(prompt).toContain("Building, testing, and delivering the requested agent is the primary task"); + expect(prompt).toContain("no role, approval, confirmation, or mode transition"); + expect(prompt).toContain("clear initial request"); + }); + + it("distinguishes the shared map from automatic per-agent rendering", () => { + expect(prompt).toContain("agent-map"); + expect(prompt).toContain("not the automatically rendered per-agent Canvas"); + expect(prompt).toContain("does not update itself from code edits"); + expect(prompt).toContain("summary.md"); + expect(prompt).toContain("Before reporting completion"); + }); + + it("teaches discovery, empty-map creation, and conflict-safe persistence", () => { + expect(prompt).toContain("discover their schemas"); + expect(prompt).toContain("proposalId: null"); + expect(prompt).toContain("expectedVersion: 0"); + expect(prompt).toContain("draftRef"); + expect(prompt).toContain("agent_map_validate"); + expect(prompt).toContain("agent_map_propose"); + expect(prompt).toContain("Re-read and reconcile"); + }); + + it("covers the plan lifecycle without requiring a plan for small edits", () => { + for (const name of ["build_plan_read", "build_plan_validate", "build_plan_apply", + "build_plan_rebase", "build_plan_brief_refresh"]) expect(prompt).toContain(name); + expect(prompt).toContain("Small edits do not need a new plan"); + expect(prompt).toContain('kind: "current"'); + }); + + it("describes writable delegation and its actual completion boundary", () => { + expect(prompt).toContain("project_subsession_delegate"); + expect(prompt).toContain("share the working directory"); + expect(prompt).toContain("not completed work"); + expect(prompt).toContain("Never relabel, close"); + expect(prompt).toContain("release-dormant"); + }); +}); diff --git a/packages/harness/src/profiles/project-agent.ts b/packages/harness/src/profiles/project-agent.ts index 7b6d68e7a..8d58764b9 100644 --- a/packages/harness/src/profiles/project-agent.ts +++ b/packages/harness/src/profiles/project-agent.ts @@ -6,13 +6,19 @@ import type { FocusedSessionContextProjection } from "../core/focused-session-co * agent; it never changes the session's tools or implementation authority. */ export const PROJECT_AGENT_PROMPT_APPENDIX = ` -You are an ordinary writable coding agent working in a shared Studio project. You can plan and implement in the same session; no role, approval, confirmation, or mode transition is required before beginning a clear implementation request. +You are an ordinary writable coding agent working in a shared Studio project. Building, testing, and delivering the requested agent is the primary task; runtime capabilities and the Sapiom authoring guide remain your main implementation references. Map, plan, and delegation tools support that delivery. You can plan and implement in the same session; no role, approval, confirmation, or mode transition is required before beginning a clear implementation request. Respect read-only requests: inspect without mutating project state. -Use agent_map_read when the current project architecture is relevant. When the work materially changes agents, meaningful subagents, responsibilities, ownership, contracts, shared resources, connectors, artifacts, sequencing boundaries, or cross-agent data flow, validate and record the change with agent_map_validate and agent_map_propose. Re-read and reconcile explicitly if another session changed the shared map concurrently. +Current Studio orientation (takes precedence over older orientation in the base prompt): this project has sapiom for runtime capabilities, sapiom-dev for authoring/testing, and agent-map for shared project tools. Proceed directly on a clear initial request without stopping for an invitation. With no task, offer one relevant next step from actual workspace state; do not assume a sample exists. Discover project tools by name if deferred, and discover their schemas before constructing calls. -Keep internal implementation details local: library choices, ordinary implementation steps, incidental model or tool calls, and refactors that do not change a meaningful project boundary do not belong in the Agent Map. Proceed directly when the user's request is already scoped for implementation. +Agent Map: this is the shared architecture, not the automatically rendered per-agent Canvas, and it does not update itself from code edits. Use agent_map_read when a request concerns project structure or changes a meaningful boundary. During implementation, if the map is empty and the request or inspected files establish agents and data flow, record an evidence-backed initial map once those boundaries are clear; do not wait for the user to request a diagram or invent placeholder nodes. Keep responsibilities, ownership, contracts, shared resources, connectors, artifacts, and cross-agent relationships current. For example, adding a saved summary.md changes the output contract: record the artifact and its writes/reads relationships even if no agent was added. Distinguish manual handoffs from implemented automatic calls; a map edge does not implement execution. -Project and bootstrap context never grant or remove authority. +Map sequence: read -> agent_map_validate -> agent_map_propose with the same valid batch. An empty proposal starts with proposalId: null and expectedVersion: 0; otherwise copy the proposal ID/version from the read. Use draftRef for new nodes and returned IDs afterward. Validation alone does not save the map; successful propose does. Re-read and reconcile explicitly on conflicts, preserving unrelated work. Retry identical requests with the same request ID; changed requests need a fresh ID. Before reporting completion of an implementation request, check that the map reflects meaningful changes made in this turn, including new artifacts and changed contracts; verify persisted changes with a read. If updating fails, report the gap instead of claiming the map is current. Library choices, incidental tool/model calls, ordinary steps, and boundary-preserving refactors stay out of the map. + +Build plans: use a shared plan for substantial multi-agent work with assignments, dependencies, sequencing, or acceptance criteria; maintain an existing relevant plan. Small edits do not need a new plan. Start with build_plan_read({ kind: "current" }) for the current plan and exact map/plan references. After a map exists, use build_plan_validate then build_plan_apply with the same complete replacement, preserving unrelated plan content. Use returned IDs/digests, not guesses. After the map version changes, re-read and use build_plan_rebase before further plan edits, explicitly reconciling invalidated references. Apply/rebase already attempt canonical brief refresh; inspect that result and use build_plan_brief_refresh for focused briefs or to retry failed refresh independently. These are context documents, not execution or approval gates. + +Writable subsessions: use project_subsession_delegate when independent implementation work can proceed in parallel, or a bounded task benefits from focused context. Give each child a concrete outcome, owned files/boundaries, non-goals, deliverables, and checks. Children share the working directory, not isolated worktrees; avoid overlapping edits. A map/plan/focus is optional. Use stable requestKey/delegationKey values for identical retries. A ready session or acknowledged kickoff is not completed work: agree on written deliverables and inspect/test them; this tool does not return a finished implementation. Focused assignments, map-node references, bootstrap context, and briefs never grant or remove authority. + +Release your coordinator-owned child bindings only when their work is no longer needed. For exhausted dormant history, the bounded project-wide release-dormant operation releases only bindings atomically rechecked as exited or failed, regardless of parent liveness. This preserves ordinary conversation history but forfeits automatic resume through those bindings and expires prior request keys; later delegation needs a fresh request key and creates a fresh binding/session. Never relabel, close, or otherwise reconcile unrelated user-created sessions. `; /** diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index 731c84cdc..5f91a96a5 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -47,16 +47,16 @@ const batchSchema = z ), proposalId: preserveInvalidForService( proposalBatchRequestSchema.shape.proposalId, - ), + ).describe("Copy proposal.id from agent_map_read; null only when its proposal is null."), expectedVersion: preserveInvalidForService( proposalBatchRequestSchema.shape.expectedVersion, - ), + ).describe("Copy proposal.version from the read; 0 only for an empty proposal. Re-read after a conflict."), requestId: preserveInvalidForService( proposalBatchRequestSchema.shape.requestId, - ), + ).describe("Caller-chosen retry identity. Reuse for an identical batch; use a fresh ID when the batch changes."), operations: preserveInvalidForService( proposalBatchRequestSchema.shape.operations, - ), + ).describe("Complete atomic batch. New nodes use draftRef; existing nodes use IDs from the read. Preserve unrelated architecture."), }) .strict(); @@ -85,18 +85,18 @@ const delegationFocusSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("map-node"), map: mapVersionRefSchema, plan: planVersionRefSchema.nullable(), nodeId: versionId }).strict(), z.object({ kind: z.literal("brief"), brief: briefVersionRefSchema }).strict(), -]); +]).describe("Optional exact context, not permission. Unlike build_plan_* input refs, these refs include projectId; copy real returned IDs/digests."); const delegationKey = z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/u); const projectSubsessionRequestSchema = z.object({ schemaVersion: z.literal(1), - requestKey: delegationKey, + requestKey: delegationKey.describe("Stable caller-owned key for identical retries of this operation; changed operation content needs a fresh key."), operation: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("delegate"), delegations: z.array(z.object({ - delegationKey, - outcome: z.string().min(1).max(4_096), - kickoffContext: z.string().min(1).max(16_384).optional(), + delegationKey: delegationKey.describe("Stable child-task identity within this parent. Identical content reuses its session; different work needs a new key."), + outcome: z.string().min(1).max(4_096).describe("Concrete implementation outcome; the child is an ordinary writable coding session."), + kickoffContext: z.string().min(1).max(16_384).optional().describe("Owned files, boundaries, non-goals, written deliverables, and verification. Children share the parent's cwd, not isolated worktrees."), focus: delegationFocusSchema.optional(), }).strict()).min(1).max(16), }).strict(), @@ -106,17 +106,17 @@ const projectSubsessionRequestSchema = z.object({ z.object({ kind: z.literal("self") }).strict(), z.object({ kind: z.literal("child"), delegationKey }).strict(), ]), - expectedContextEpoch: z.number().int().positive(), - expectedContextDigest: digest, + expectedContextEpoch: z.number().int().positive().describe("Exact epoch from the current focused-context result; never guess."), + expectedContextDigest: digest.describe("Exact digest paired with expectedContextEpoch."), focus: delegationFocusSchema.nullable(), }).strict(), z.object({ kind: z.literal("release"), - delegationKeys: z.array(delegationKey).min(1).max(16), + delegationKeys: z.array(delegationKey).min(1).max(16).describe("Only this parent's owned children. Release closes their real sessions; do not release useful active work."), }).strict(), z.object({ kind: z.literal("release-dormant"), - limit: z.number().int().min(1).max(16), + limit: z.number().int().min(1).max(16).describe("Maximum exited/failed coordinator bindings to evict project-wide. Preserves conversation history but forfeits automatic resume identity."), }).strict(), ]), }).strict(); @@ -251,7 +251,7 @@ export function createAgentMapToolServer( "agent_map_read", { description: - "Read the current confirmed workspace and shared Agent Map proposal.", + "Read shared project architecture before creating or changing agents, responsibilities, contracts, resources, artifacts, or data flow. Returns workspace and proposal (null if empty), including stable IDs and the numeric proposal version for validate/propose. This is not the automatic per-agent Canvas. For exact map/plan digests use build_plan_read({kind:'current'}).", inputSchema: z.object({}).strict(), annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -274,7 +274,7 @@ export function createAgentMapToolServer( "agent_map_validate", { description: - "Validate a complete proposal batch without mutating shared state or allocating IDs.", + "Preview a complete Agent Map change batch without persisting it or allocating IDs. First agent_map_read; use its proposal ID/version, or null/0 when empty. Use draftRef for new nodes and their relationships. Correct reported issues, then pass the same valid batch to agent_map_propose. Validation alone never updates the visible map.", inputSchema: batchSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -292,7 +292,7 @@ export function createAgentMapToolServer( "agent_map_propose", { description: - "Atomically apply an idempotent batch to the shared Proposed Agent Map.", + "Persist an atomic, idempotent Agent Map batch and update the shared visible graph; this is not an approval request or code execution. Read then validate first. Reuse the request ID only for an identical retry; re-read/reconcile stale versions without overwriting unrelated work. Record meaningful artifact/contract changes, not just new agents, and confirm persisted state with agent_map_read.", inputSchema: batchSchema, annotations: { readOnlyHint: false, @@ -313,7 +313,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_read", { - description: "Read the current shared build plan or one exact immutable historical version.", + description: "Read with {kind:'current'} for the shared build plan (possibly null), current exact map/plan references, history, and diagnostics. Use these IDs/digests for plan authoring; agent_map_read does not provide map digests. Read {kind:'exact',planId,versionId,semanticDigest} for one immutable historical plan. An absent plan does not block coding or delegation.", inputSchema: buildPlanReadToolInputSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -326,7 +326,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_validate", { - description: "Preview and validate an exact-source build plan replacement without changing durable state.", + description: "Validate a complete build-plan replacement without changing durable state. Read current references first; a map must exist. Supply all content collections, preserve unrelated intent, and use clientRef for new plan-owned IDs. expectedPlan is null only for the first plan. Rebase an existing plan after any map-version change, then validate and apply the identical request.", inputSchema: buildPlanApplyRequestSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -339,7 +339,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_apply", { - description: "Atomically append an idempotent shared build plan version using exact map and plan expectations.", + description: "Persist a validated complete build-plan replacement for substantial coordinated work, not every small edit. Use exact references from build_plan_read; replacements must preserve unrelated content. Identical request-ID retries are idempotent. The plan commits before best-effort canonical briefRefresh: inspect that separate result and retry failed refresh independently. This does not launch sessions or execute code.", inputSchema: buildPlanApplyRequestSchema, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, @@ -357,7 +357,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_rebase", { - description: "Rebase the exact current build plan to the exact current map with explicit remap or removal resolutions.", + description: "Rebind the current build plan after any map-version change before further plan edits. Read current state: fromMap is plan.map, toMap is current.map, expectedPlan is current.buildPlan; input refs omit projectId. Use resolutions:[] if all references remain valid, otherwise explicitly remap/remove invalidated references without silently dropping intent. Commits before best-effort canonical brief refresh.", inputSchema: buildPlanRebaseRequestSchema, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, @@ -375,7 +375,7 @@ export function createAgentMapToolServer( server.registerTool( "build_plan_brief_refresh", { - description: "Compile or refresh exact-source canonical or focused briefs without changing plan-authoring results.", + description: "Compile context briefs from exact current matching map and plan versions. Use canonical focus for workstreams or focused selections for bounded ad-hoc assignments. Apply/rebase already attempt canonical refresh; retry this tool independently if that refresh failed. Refresh does not change plan intent, launch a session, or grant implementation authority; a running child's context refresh is a delegation operation.", inputSchema: agentBriefRefreshRequestSchema, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, }, @@ -388,7 +388,7 @@ export function createAgentMapToolServer( server.registerTool( "project_subsession_delegate", { - description: "Create, reuse, or release a bounded batch of ordinary writable project subsessions, reclaim a bounded project-wide set of coordinator-owned dormant bindings, or refresh exact focused context, using caller-owned idempotency keys.", + description: "Delegate bounded implementation work to ordinary writable children when parallel work or focused context helps. operation.kind='delegate' uses stable requestKey and per-child delegationKey, outcome, and optional kickoffContext/focus; no map or plan is required. Children share cwd, so specify non-overlapping ownership and written deliverables. Ready/acknowledged is kickoff state, not completed work; inspect and test deliverables. Other operations refresh exact focused context, release owned children (closing sessions), or release-dormant project-wide (forfeiting dormant resume bindings, preserving history). Never reconcile unrelated user sessions.", inputSchema: projectSubsessionRequestSchema, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, }, 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 911345775..e6648b9e7 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -653,6 +653,51 @@ it("creates one ordinary Plan Agents session for a newly opened project and neve expect(launches).toHaveLength(1); }); +it.each([false, true])("retains a first project's scope during preparation and refresh (fresh catalog: %s)", async (fresh) => { + await fs.writeFile(path.join(root, "settings.json"), JSON.stringify({ recentDirs: [] })); + if (fresh) { + await fs.writeFile(path.join(root, "studio-projects.json"), JSON.stringify({ schemaVersion: 1, projects: [] })); + } + const preparing = deferred(); + const release = deferred(); + const launch = vi.fn((opts: LaunchOpts): SpawnSpec => ({ command: "bash", args: [], env: {}, cwd: opts.cwd })); + const adapter: HarnessAdapter = { + id: "claude-code", eventSource: "hooks", doctor: async () => [], launch, + resume: (_id, opts) => launch(opts), listPastSessions: async () => [], canResume: async () => true, + }; + const cwd = path.join(projectRoot, "new-project"); + server = await startServer({ + port: 0, bootToken: "boot-token", telemetryOptIn: false, authMode: "disabled", + adapters: { "claude-code": adapter }, stateRoot: root, launchDir: projectRoot, + autoCreateSession: false, + buildLaunchOpts: async (_id, req) => { + await fs.mkdir(req.cwd, { recursive: true }); + preparing.resolve(); + await release.promise; + return {}; + }, + }); + const headers = { "content-type": "application/json", "x-harness-token": "boot-token" }; + const creating = fetch(`http://127.0.0.1:${server.port}/api/sessions`, { + method: "POST", headers, body: JSON.stringify({ cwd, harness: "claude-code", initialPrompt: "Build ticket triage." }), + }); + await preparing.promise; + try { + const state = await (await fetch(`http://127.0.0.1:${server.port}/api/state`, { + headers, signal: AbortSignal.timeout(2000), + })).json(); + expect(launch).not.toHaveBeenCalled(); + expect(state.sessions).toHaveLength(0); + } finally { + release.resolve(); + } + const response = await creating; + expect(response.status).toBe(201); + expect(launch).toHaveBeenCalledOnce(); + expect(server.sessionManager.list()).toHaveLength(1); + expect(server.sessionManager.listPendingCreates()).toEqual([]); +}); + it("does not spawn an automatic duplicate when an explicit first session wins the bootstrap claim", async () => { const launches: LaunchOpts[] = []; const adapter: HarnessAdapter = { @@ -717,7 +762,7 @@ it("does not spawn an automatic duplicate when an explicit first session wins th body: JSON.stringify({ cwd: freshRoot, harness: "claude-code", - initialUserInputPending: true, + initialPrompt: "Build a ticket triage agent.", }), }); expect(explicitResponse.status).toBe(201); @@ -739,6 +784,7 @@ it("does not spawn an automatic duplicate when an explicit first session wins th }); expect(explicit.title).toBe("Plan Agents"); expect(launches).toHaveLength(1); + expect(launches[0]?.initialPrompt).toBe("Build a ticket triage agent."); }); it("automatically seeds one durable map through the real E2 tools without replaying after duplicate readiness or restart", async () => { diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index 0a5fcb2d1..e31228e73 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -25,6 +25,7 @@ import { AgentMapMcpProjectUnavailableError, createAgentMapToolServer, } from "./agent-map-mcp-tools.js"; +import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; const projectId = "project_00000000-0000-4000-8000-000000000001"; const clients: Client[] = []; @@ -120,6 +121,22 @@ describe("Agent Map Streamable HTTP MCP", () => { "build_plan_validate", "project_subsession_delegate", ]); + for (const tool of tools.tools) { + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain(tool.name); + } + const descriptions = Object.fromEntries(tools.tools.map(({ name, description }) => [name, description])); + expect(descriptions.agent_map_read).toContain("null if empty"); + expect(descriptions.agent_map_validate).toContain("Validation alone never updates"); + expect(descriptions.agent_map_propose).toContain("not an approval request"); + expect(descriptions.build_plan_read).toContain("current"); + expect(descriptions.build_plan_validate).toContain("preserve unrelated intent"); + expect(descriptions.build_plan_apply).toContain("retry failed refresh independently"); + expect(descriptions.build_plan_rebase).toContain("resolutions:[]"); + expect(descriptions.build_plan_brief_refresh).toContain("does not change plan intent"); + expect(descriptions.project_subsession_delegate).toContain("not completed work"); + const mapInput = tools.tools.find(({ name }) => name === "agent_map_propose")!.inputSchema; + expect(JSON.stringify(mapInput.properties?.proposalId)).toContain("agent_map_read"); + expect(JSON.stringify(mapInput.properties?.expectedVersion)).toContain("0"); const nonStrict = tools.tools.filter((tool) => !(tool.inputSchema.additionalProperties === false || (Array.isArray(tool.inputSchema.anyOf) && tool.inputSchema.anyOf.every((variant) => typeof variant === "object" && variant !== null && "additionalProperties" in variant && diff --git a/packages/harness/src/server/attachments.ts b/packages/harness/src/server/attachments.ts new file mode 100644 index 000000000..bdd08346b --- /dev/null +++ b/packages/harness/src/server/attachments.ts @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { resolveWithinRoot } from "../core/path-safety.js"; +import { + HARNESS_UPLOADS_DIR, + MAX_INLINE_ATTACHMENT_BYTES, + type AttachFileRequest, + type AttachFileResponse, +} from "../shared/types.js"; + +export class AttachmentError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +/** Validate standard padded base64 in one pass and return decoded size. */ +function decodedBase64Size(encoded: string): number | null { + if (encoded.length === 0 || encoded.length % 4 !== 0) return null; + const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + const contentLength = encoded.length - padding; + for (let index = 0; index < encoded.length; index += 1) { + const code = encoded.charCodeAt(index); + const isDataCharacter = + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 43 || + code === 47; + if (index < contentLength ? !isDataCharacter : code !== 61) return null; + } + return (encoded.length / 4) * 3 - padding; +} + +/** Validate size before allocating decoded bytes or creating any files. */ +export function validateAttachment(request: AttachFileRequest): { + mediaType: string; + encoded: string; + bytes: number; +} { + const match = /^data:([a-z0-9.+/-]+);base64,([\s\S]+)$/i.exec( + request.dataUrl, + ); + if (!match) + throw new AttachmentError(400, "dataUrl must be a base64 data: URL"); + const encoded = match[2]!; + const bytes = decodedBase64Size(encoded); + if (bytes === null) + throw new AttachmentError(400, "attachment payload is not valid base64"); + if (bytes === 0) + throw new AttachmentError(400, "attachment payload is empty"); + if (bytes > MAX_INLINE_ATTACHMENT_BYTES) { + throw new AttachmentError( + 413, + `Attachment is ${bytes} bytes; the limit is ${MAX_INLINE_ATTACHMENT_BYTES} bytes`, + ); + } + return { mediaType: match[1]!.toLowerCase(), encoded, bytes }; +} + +/** Both first-turn and later uploads use the same containment/filename rules. */ +export async function writeAttachment( + cwd: string, + request: AttachFileRequest, +): Promise { + const { mediaType, encoded, bytes } = validateAttachment(request); + const uploadsDir = resolveWithinRoot(cwd, HARNESS_UPLOADS_DIR); + if (!uploadsDir) + throw new AttachmentError(500, "could not resolve the uploads directory"); + await fs.mkdir(uploadsDir, { recursive: true }); + const [realCwd, realUploadsDir] = await Promise.all([ + fs.realpath(cwd), + fs.realpath(uploadsDir), + ]); + if (!resolveWithinRoot(realCwd, realUploadsDir)) { + throw new AttachmentError(400, "uploads directory escapes the session cwd"); + } + const requestedExtension = path.extname(path.basename(request.filename)); + const extension = /^\.[a-z0-9]{1,12}$/i.test(requestedExtension) + ? requestedExtension.toLowerCase() + : ".bin"; + const filePath = path.join(realUploadsDir, `${randomUUID()}${extension}`); + await fs.writeFile(filePath, Buffer.from(encoded, "base64"), { flag: "wx" }); + return { path: filePath, mediaType, bytes }; +} diff --git a/packages/harness/src/server/codex-session-lifecycle.test.ts b/packages/harness/src/server/codex-session-lifecycle.test.ts index 36bcba526..7e1e931c9 100644 --- a/packages/harness/src/server/codex-session-lifecycle.test.ts +++ b/packages/harness/src/server/codex-session-lifecycle.test.ts @@ -106,7 +106,7 @@ describe("codex session lifecycle (real files, no mocking)", () => { const session = await server.sessionManager.create({ cwd, harness: "codex" }); expect(session.status).toBe("running"); - // Simulate Codex creating its rollout file shortly after being spawned — + // Simulate Codex creating its rollout file on the first user turn — // real discovery has to poll for this, since there's no way to know the // exact timestamp+UUID path in advance. const agentSessionId = "019e00000000-integration-test"; @@ -117,7 +117,9 @@ describe("codex session lifecycle (real files, no mocking)", () => { // this machine's tmpdir that differs from the raw path (e.g. macOS's // /var -> /private/var), so mirror that here rather than the raw value, // to actually exercise findRolloutFile's realpath-normalized matching. - await writeFile(rolloutPath, sessionMetaLine(agentSessionId, await realpath(cwd), new Date().toISOString())); + await writeFile(rolloutPath, + sessionMetaLine(agentSessionId, await realpath(cwd), new Date().toISOString()) + + userMessageLine("build me a leasing workflow")); // --- session.start, and the rollout id links into the registry --- await vi.waitFor( @@ -129,8 +131,7 @@ describe("codex session lifecycle (real files, no mocking)", () => { { timeout: 10_000, interval: 200 }, ); - // --- live activity: a prompt, a tool call, and the turn completing --- - await appendFile(rolloutPath, userMessageLine("build me a leasing workflow")); + // --- live activity: a tool call and the first turn completing --- await appendFile(rolloutPath, functionCallLine("call_1", "exec_command", '{"cmd":"ls"}')); await appendFile(rolloutPath, functionCallOutputLine("call_1", "file1.txt\nfile2.txt")); await appendFile(rolloutPath, taskCompleteLine()); diff --git a/packages/harness/src/server/created-agent-wiring.test.ts b/packages/harness/src/server/created-agent-wiring.test.ts new file mode 100644 index 000000000..a46b961c5 --- /dev/null +++ b/packages/harness/src/server/created-agent-wiring.test.ts @@ -0,0 +1,219 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import type { + AppState, + BusMessage, + HarnessAdapter, + LaunchOpts, + SpawnSpec, +} from "../shared/types.js"; +import { startServer, type HarnessServer } from "./index.js"; + +let root: string | undefined; +let server: HarnessServer | undefined; +let socket: WebSocket | undefined; +afterEach(async () => { + socket?.close(); + await server?.close(); + if (root) await fs.rm(root, { recursive: true, force: true, maxRetries: 5 }); +}); + +it( + "publishes a scaffolded sibling immediately, preserves the conversation, and restores membership on restart", + { timeout: 25_000 }, + async () => { + root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "created-agent-wiring-")), + ); + const stateRoot = path.join(root, "state"); + const projectRoot = path.join(root, "original"); + const reviewer = path.join(root, "reviewer"); + const unrelated = path.join(root, "unrelated"); + const tokenPath = path.join(root, "test-ingest-token"); + await fs.mkdir(stateRoot); + await fs.mkdir(projectRoot); + await fs.writeFile(path.join(projectRoot, "sapiom.json"), "{}"); + await fs.writeFile( + path.join(projectRoot, "package.json"), + JSON.stringify({ name: "original" }), + ); + await fs.writeFile( + path.join(stateRoot, "settings.json"), + JSON.stringify({ recentDirs: [projectRoot] }), + ); + const launch = (opts: LaunchOpts): SpawnSpec => ({ + command: process.execPath, + args: [ + "-e", + "require('fs').writeFileSync(process.env.SAPIOM_TEST_TOKEN_PATH, process.env.SAPIOM_HARNESS_INGEST_TOKEN); setInterval(() => {}, 1000)", + ], + cwd: opts.cwd, + env: { SAPIOM_TEST_TOKEN_PATH: tokenPath }, + }); + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch, + resume: (_id, opts) => launch(opts), + listPastSessions: async () => [], + canResume: async () => true, + }; + const start = () => + startServer({ + port: 0, + bootToken: "test-token", + telemetryOptIn: false, + stateRoot, + launchDir: projectRoot, + adapters: { "claude-code": adapter }, + autoCreateSession: false, + loadSystemPrompt: async () => "", + machineId: "test-machine", + authMode: "disabled", + }); + const headers = { + "X-Harness-Token": "test-token", + "Content-Type": "application/json", + }; + server = await start(); + const state = async (): Promise => + ( + await fetch(`http://127.0.0.1:${server!.port}/api/state`, { headers }) + ).json() as Promise; + const session = await server.sessionManager.create({ + cwd: projectRoot, + harness: "claude-code", + }); + const projectId = session.agentMapIdentity!.projectId; + let token = ""; + await vi.waitFor(async () => { + token = await fs.readFile(tokenPath, "utf8"); + expect(token).not.toBe(""); + }); + const started = await fetch(`http://127.0.0.1:${server.port}/ingest`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + hookEvent: "SessionStart", + harnessSessionId: session.id, + payload: { + session_id: "original-claude-conversation", + source: "startup", + }, + }), + }); + expect(started.status).toBe(200); + socket = new WebSocket( + `ws://127.0.0.1:${server.port}/ws/events?token=test-token`, + ); + await new Promise((resolve, reject) => { + socket!.once("open", resolve); + socket!.once("error", reject); + }); + let changed = 0; + socket.on("message", (raw) => { + if ( + (JSON.parse(raw.toString()) as BusMessage).type === "workflows.changed" + ) + changed++; + }); + for (const dir of [reviewer, unrelated]) { + await fs.mkdir(dir); + await fs.writeFile(path.join(dir, "sapiom.json"), "{}"); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ name: path.basename(dir) }), + ); + } + const response = await fetch(`http://127.0.0.1:${server.port}/ingest`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + hookEvent: "PostToolUse", + harnessSessionId: session.id, + payload: { + session_id: "original-claude-conversation", + tool_name: "mcp__sapiom-dev__sapiom_dev_agents_scaffold", + tool_input: { dir: reviewer }, + tool_response: [ + { + type: "text", + text: JSON.stringify({ + targetDir: reviewer, + projectName: "reviewer", + template: "default", + gitInitialized: true, + dependenciesInstalled: true, + }), + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + await vi.waitFor( + async () => { + const current = await state(); + expect( + current.workflows.find((workflow) => workflow.path === reviewer) + ?.studioBindings, + ).toMatchObject([{ projectId }]); + expect( + current.workflows.some((workflow) => workflow.path === unrelated), + ).toBe(false); + expect(current.studioProjects).toHaveLength(1); + expect(current.sessions.map((value) => value.id)).toEqual([session.id]); + expect(current.sessions[0]?.agentSessionId).toBe( + "original-claude-conversation", + ); + expect(changed).toBeGreaterThan(0); + }, + { timeout: 8_000 }, + ); + const binding = (await state()).workflows.find( + (workflow) => workflow.path === reviewer, + )!.studioBindings![0]!; + const selection = await fetch( + `http://127.0.0.1:${server.port}/api/projects/${projectId}/current-workspace`, + { + method: "PUT", + headers, + body: JSON.stringify({ selection: { kind: "agent", ...binding } }), + }, + ); + expect(selection.status).toBe(200); + expect(await selection.json()).toMatchObject({ + selection: { kind: "agent", ...binding }, + }); + socket.close(); + await server.close(); + server = await start(); + expect( + (await state()).workflows.find((workflow) => workflow.path === reviewer) + ?.studioBindings, + ).toEqual([binding]); + expect((await state()).studioProjects).toHaveLength(1); + // The exact sibling watcher remains alive without resuming its old session. + await fs.unlink(path.join(reviewer, "sapiom.json")); + await vi.waitFor( + async () => { + expect( + (await state()).workflows.some( + (workflow) => workflow.path === reviewer, + ), + ).toBe(false); + }, + { timeout: 8_000 }, + ); + }, +); diff --git a/packages/harness/src/server/first-request.test.ts b/packages/harness/src/server/first-request.test.ts new file mode 100644 index 000000000..ddf810ab9 --- /dev/null +++ b/packages/harness/src/server/first-request.test.ts @@ -0,0 +1,136 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { prepareFirstRequest } from "./first-request.js"; +import type { AgentScaffoldDeps } from "./scaffold.js"; + +let root: string; +let deps: AgentScaffoldDeps; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "harness-first-request-")); + deps = { + listProjectDirs: () => [root], + resolveAgent: () => null, + scaffoldAgent: vi.fn(async ({ targetDir }) => { + await writeFile(join(targetDir, "AGENTS.md"), "Project instructions"); + return { dependenciesInstalled: true }; + }), + onScaffolded: vi.fn(async () => {}), + }; +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +it("scaffolds with the existing server guards, then returns only the user's first task", async () => { + const cwd = join(root, "ticket-triage"); + const prompt = "--help\nBuild a local support-ticket triage project."; + expect( + await prepareFirstRequest( + { + cwd, + harness: "claude-code", + scaffold: { template: "default" }, + initialPrompt: prompt, + }, + deps, + ), + ).toBe(prompt); + expect(deps.scaffoldAgent).toHaveBeenCalledExactlyOnceWith({ + targetDir: cwd, + template: "default", + }); + expect(deps.onScaffolded).toHaveBeenCalledExactlyOnceWith(cwd); + expect(await readFile(join(cwd, "AGENTS.md"), "utf8")).toBe( + "Project instructions", + ); +}); + +it("materializes clipboard bytes and preserves mixed attachment order before launch", async () => { + const cwd = join(root, "ticket-triage"); + const prompt = await prepareFirstRequest( + { + cwd, + harness: "codex", + scaffold: { template: "default" }, + initialPrompt: "Use my files.", + initialAttachments: [ + { kind: "path", path: "/native/first brief.pdf" }, + { + kind: "inline", + filename: "../../screenshot.PNG", + dataUrl: "data:image/png;base64,cGl4ZWxz", + }, + { kind: "path", path: "/native/last.txt" }, + ], + }, + deps, + ); + const [upload] = await readdir(join(cwd, ".sapiom/uploads")); + const uploadedPath = join(cwd, ".sapiom/uploads", upload!); + expect(upload).toMatch(/^[a-f0-9-]+\.png$/); + expect(await readFile(uploadedPath, "utf8")).toBe("pixels"); + expect(prompt).toBe( + `Use my files.\n\nAttached files (read each as context):\n"/native/first brief.pdf"\n${uploadedPath}\n/native/last.txt`, + ); +}); + +it("rejects invalid attachments before creating the project or starting a session", async () => { + await expect( + prepareFirstRequest( + { + cwd: join(root, "invalid"), + harness: "claude-code", + scaffold: { template: "default" }, + initialAttachments: [ + { + kind: "inline", + filename: "bad.txt", + dataUrl: "data:text/plain;base64,not base64", + }, + ], + }, + deps, + ), + ).rejects.toMatchObject({ status: 400 }); + expect(deps.scaffoldAgent).not.toHaveBeenCalled(); + expect(await readdir(root)).toEqual([]); +}); + +it("refuses an unregistered parent and never overwrites an existing project", async () => { + await expect( + prepareFirstRequest( + { + cwd: join(root, "unregistered", "agent"), + harness: "claude-code", + scaffold: { template: "default" }, + }, + deps, + ), + ).rejects.toMatchObject({ status: 409 }); + const request = { + cwd: join(root, "agent"), + harness: "claude-code" as const, + scaffold: { template: "default" }, + }; + await prepareFirstRequest(request, deps); + await expect(prepareFirstRequest(request, deps)).rejects.toMatchObject({ + status: 409, + }); + expect(deps.scaffoldAgent).toHaveBeenCalledOnce(); +}); + +it("an attachment-only request supplies context without a synthetic scaffold instruction", async () => { + expect( + await prepareFirstRequest( + { + cwd: root, + harness: "claude-code", + initialAttachments: [{ kind: "path", path: "/native/brief.txt" }], + }, + deps, + ), + ).toBe("Attached files (read each as context):\n/native/brief.txt"); + expect(deps.scaffoldAgent).not.toHaveBeenCalled(); +}); diff --git a/packages/harness/src/server/first-request.ts b/packages/harness/src/server/first-request.ts new file mode 100644 index 000000000..a00bd5d83 --- /dev/null +++ b/packages/harness/src/server/first-request.ts @@ -0,0 +1,70 @@ +import * as fs from "node:fs/promises"; +import { basename, dirname } from "node:path"; +import { + MAX_INLINE_ATTACHMENTS_TOTAL_BYTES, + type CreateSessionRequest, +} from "../shared/types.js"; +import { buildIdeaWithAttachments } from "../shared/initial-prompt.js"; +import { + AttachmentError, + validateAttachment, + writeAttachment, +} from "./attachments.js"; +import { scaffoldAgentProject, type AgentScaffoldDeps } from "./scaffold.js"; + +export function validateInitialAttachments( + attachments: CreateSessionRequest["initialAttachments"], +): void { + let bytes = 0; + for (const attachment of attachments ?? []) { + if (attachment.kind !== "inline") continue; + bytes += validateAttachment(attachment).bytes; + if (bytes > MAX_INLINE_ATTACHMENTS_TOTAL_BYTES) { + throw new AttachmentError( + 413, + "Pasted files exceed the per-session attachment limit", + ); + } + } +} + +/** Prepare files before the CLI exists; there is no synthetic PTY Enter. */ +export async function prepareFirstRequest( + request: CreateSessionRequest, + scaffoldDeps: AgentScaffoldDeps, +): Promise { + validateInitialAttachments(request.initialAttachments); + if (request.scaffold) { + await scaffoldAgentProject(scaffoldDeps, { + root: dirname(request.cwd), + name: basename(request.cwd), + template: request.scaffold.template, + }); + } + const resolved: { path: string }[] = []; + const uploaded: string[] = []; + try { + for (const attachment of request.initialAttachments ?? []) { + if (attachment.kind === "path") { + resolved.push({ path: attachment.path }); + } else { + try { + const file = await writeAttachment(request.cwd, attachment); + uploaded.push(file.path); + resolved.push(file); + } catch (error) { + throw new AttachmentError( + error instanceof AttachmentError ? error.status : 500, + `Couldn't attach ${attachment.filename}: ${(error as Error).message}`, + ); + } + } + } + return buildIdeaWithAttachments(request.initialPrompt ?? "", resolved); + } catch (error) { + // Only our UUID-named uploads are disposable. Keep a completed scaffold + // (and all pre-existing files); no coding session has started yet. + await Promise.all(uploaded.map((file) => fs.unlink(file).catch(() => {}))); + throw error; + } +} diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index f69b8b4c2..74273d021 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -9,6 +9,7 @@ import { SubsessionCoordinatorStore, type SubsessionCoordinatorStoreEvent } from "../core/subsession-coordinator-store.js"; import { SubsessionCoordinator, type SubsessionCoordinatorEvent } from "../core/subsession-coordinator.js"; +import { codexRuntimeMarker } from "../core/collector/codex-runtime-marker.js"; import { CodexRolloutBroker } from "../core/collector/codex-rollout-broker.js"; import { AgentBriefService } from "../core/agent-brief-service.js"; import { BuildPlanStore } from "../core/build-plan-store.js"; @@ -37,7 +38,7 @@ import type { SystemPromptDelivery, WorkflowInfo, } from "../shared/types.js"; -import { JSON_BODY_LIMIT_BYTES } from "../shared/types.js"; +import { CREATE_SESSION_JSON_LIMIT_BYTES, JSON_BODY_LIMIT_BYTES } from "../shared/types.js"; import type { ProjectBootstrapLifecycleEvent, ProjectAgentSession, @@ -78,6 +79,7 @@ import { import { AgentSourceScanBudget } from "../core/agent-source-discovery.js"; import { DEFAULT_MACROS } from "../core/macros.js"; import { createEventStore } from "../core/collector/store.js"; +import { CreatedAgentRegistration } from "../core/created-agent-registration.js"; import { createClaudeTranscriptEnricher, createSessionRecordReader, @@ -221,7 +223,8 @@ import { moveTargetDirs, remapSessions, } from "./agent-move.js"; -import { createAgentScaffoldRouter } from "./scaffold.js"; +import { createAgentScaffoldRouter, type AgentScaffoldDeps } from "./scaffold.js"; +import { prepareFirstRequest } from "./first-request.js"; import { createMacrosRouter } from "./macros.js"; import { createFsRouter } from "./fs.js"; import { createRunsRouter } from "./runs.js"; @@ -1317,6 +1320,56 @@ export const startServer = async ( }, }); + const scaffoldDeps: AgentScaffoldDeps = { + listProjectDirs: async () => { + const stored = await loadSettings(statePaths.settings); + return moveTargetDirs( + [ + ...stored.recentDirs, + ...(stored.projectRoot ? [stored.projectRoot] : []), + // THE HOST'S DEFAULT, which the move route does not need and this + // one does. `AppState.defaultProjectRoot` is where the SPA puts a + // NEW project when the user has saved no `projectRoot` of their own + // — `/projects` under Electron — and the host does not + // persist it into settings. Without it, the first template a user + // ever starts from is refused at its own suggested destination + // ("Studio doesn't show that folder as a project"), and the flow + // cannot bootstrap: `recentDirs` only learns a root once a session + // has been created there, and creation now happens FIRST. + defaultProjectRoot ?? launchDir, + ...sessionManager.list().map((session) => session.cwd), + ], + workflowsCache.map((w) => w.path), + ); + }, + resolveAgent: (agentPath) => + workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null, + scaffoldAgent: async ({ targetDir, template }) => { + // `installDependencies: true` for the same reason the MCP tool passes + // it: the Canvas bundles the project on its first, unprompted render + // and resolves `@sapiom/agent`/`zod` from the project's own + // node_modules, so a never-installed agent opens on a "Could not + // resolve …" error. Best-effort inside agent-core — a failed install + // still returns a created project. + const result = await scaffold({ + targetDir, + template, + templatesDir: agentCoreTemplatesDir(), + installDependencies: true, + }); + return { dependenciesInstalled: result.dependenciesInstalled }; + }, + // Rescan the PROJECT root, not the agent directory: the registry has to + // learn the new agent under the project the rail draws it in, and the + // scan broadcasts `workflows.changed` so the row is there before the + // dialog's caller opens a session on it. + onScaffolded: async (agentDir) => { + await scanWorkflowsAndBroadcast(dirname(agentDir), "agent-created", { + dirty: true, + }); + }, + }; + const innerBuildLaunchOpts = options.buildLaunchOpts ?? createDefaultBuildLaunchOpts( @@ -1335,8 +1388,13 @@ export const startServer = async ( context, ) => { await pendingGeneratedRemovals.get(harnessSessionId); + // Scope/bootstrap ownership is already resolved; prepare the user's new + // project before config generation and PTY spawn, never during resume. + const initialPrompt = context?.resume + ? undefined + : await prepareFirstRequest(req, scaffoldDeps); if (!context?.agentMapIdentity) { - return innerBuildLaunchOpts(harnessSessionId, req, context); + return { ...(await innerBuildLaunchOpts(harnessSessionId, req, context)), ...(initialPrompt ? { initialPrompt } : {}) }; } if (!agentMapMcpUrl) { throw new Error("Agent Map MCP endpoint is not bound"); @@ -1354,7 +1412,7 @@ export const startServer = async ( ...context, agentMapMcp: agentMapMcpMetadata, }); - return { ...generated, agentMapMcp: agentMapMcpMetadata }; + return { ...generated, agentMapMcp: agentMapMcpMetadata, ...(initialPrompt ? { initialPrompt } : {}) }; } catch (error) { agentMapCapabilities.revokeSession(harnessSessionId); throw error; @@ -1507,6 +1565,7 @@ export const startServer = async ( } const metadata = await projectBootstrap?.claimProject( identity, + Boolean(request.initialPrompt || request.initialAttachments?.length) || request.initialUserInputPending === true || Boolean(request.rehydrateFrom), ); @@ -1527,6 +1586,8 @@ export const startServer = async ( runtimeEpoch, cwd: session.cwd, sinceMs: Date.now(), + ...(sessionManager.getSubsessionBinding(session.id) + ? { requiredRuntimeMarker: codexRuntimeMarker(runtimeEpoch) } : {}), }); } else { codexRolloutBroker.releaseSession(session.id); @@ -3095,6 +3156,57 @@ export const startServer = async ( const studioWorkspacePreferences = new StudioWorkspacePreferenceStore( join(statePaths.agentMap, "studio-workspace-preferences.json"), ); + // These leases observe exact, explicitly associated agent directories. They + // do not open those directories as new Studio projects or broaden cwd. + const createdAgentWatcher = new WorkspaceWatcherManager({ + sharedWatchBroker: sharedWorkspaceWatchBroker, + listSourceRoots: (_key, cwd) => [cwd], + listSourceObservations: (_key, cwd) => discoveryObservationsForRoot(cwd), + onPotentialChange: (agentPath) => { + if (coordinatorActive) prepareDirtyWorkflowRoot(agentPath); + }, + onChange: async (agentPath) => { + if (!coordinatorActive) return; + await scanWorkflowsAndBroadcast(agentPath, "workspace-change", { + dirty: true, + }); + }, + }); + const createdAgentRegistration = new CreatedAgentRegistration({ + preferences: studioWorkspacePreferences, + events: eventStore, + authorize: async (event, runtimeEpoch) => { + const session = sessionManager.get(event.harnessSessionId); + if ( + !session || + !session.agentMapIdentity || + localProjectPrincipal(event.userId, event.machineId) !== + session.agentMapIdentity.userId || + (runtimeEpoch !== undefined && + !sessionManager.acceptsIngestRuntimeEpoch(session.id, runtimeEpoch)) + ) return null; + const authorized = await isProjectSessionDispatchAuthorized({ + session, + currentPrincipal: () => localProjectPrincipal(projectUserId, machineId), + resolveProject: (projectId) => studioProjectCatalog.resolveIdentity(projectId), + }); + if ( + !authorized || + (runtimeEpoch !== undefined && + !sessionManager.acceptsIngestRuntimeEpoch(session.id, runtimeEpoch)) + ) return null; + return { projectId: session.agentMapIdentity.projectId, cwd: session.cwd }; + }, + projectForPath: async (agentPath) => + (await studioProjectCatalog.resolveIdentityForPath(agentPath))?.projectId ?? + null, + watch: (agentPath) => createdAgentWatcher.start(agentPath, agentPath), + scan: async (agentPath) => { + await scanWorkflowsAndBroadcast(agentPath, "agent-created", { dirty: true }); + // Backfill can change membership without changing any filesystem row. + bus.publish({ type: "workflows.changed" }); + }, + }); const agentMapProposalService = new AgentMapProposalService( agentMapWorkspaceStore, { @@ -3176,6 +3288,14 @@ export const startServer = async ( "sessionId" in event && event.sessionId ? event.sessionId : `subsession-${event.projectId}`; + if (event.name === "subsession.kickoff_submitted" && + sessionManager.get(eventSessionId)?.harness === "codex") { + // An idle fresh Codex may have outlived the initial discovery window. + // Submission creates its rollout; restart collection, never the kickoff. + void startCodexTailerFor(eventSessionId).catch(() => { + console.error("[harness] codex kickoff transcript discovery failed"); + }); + } const analyticsEvent: AnalyticsEvent = { eventId: randomUUID(), seq: seqCounter.next(eventSessionId), @@ -3594,6 +3714,19 @@ export const startServer = async ( const app: Express = express(); app.disable("x-powered-by"); + + // Everything under /api requires the boot token; mounted as middleware + // (not a router) so it also gates the workflows/macros routers below, + // which declare their own absolute /api/* paths. + // JSON limit raised above express's 100 KiB default so the image-attach route + // (base64 data URLs, up to ~13 MiB encoded) can be parsed — see + // JSON_BODY_LIMIT_BYTES. This is the parser that actually gates every /api + // route; the rest router mounts its own with the same limit for standalone use. + app.post( + "/api/sessions", + createBootTokenMiddleware(options.bootToken), + express.json({ limit: CREATE_SESSION_JSON_LIMIT_BYTES }), + ); app.use( "/api", createBootTokenMiddleware(options.bootToken), @@ -3988,57 +4121,7 @@ export const startServer = async ( // against the SAME directory list the move route drops into — so "a folder // the rail can show" and "a folder the studio will create a project in" stay // one answer. - app.use( - createAgentScaffoldRouter({ - listProjectDirs: async () => { - const stored = await loadSettings(statePaths.settings); - return moveTargetDirs( - [ - ...stored.recentDirs, - ...(stored.projectRoot ? [stored.projectRoot] : []), - // THE HOST'S DEFAULT, which the move route does not need and this - // one does. `AppState.defaultProjectRoot` is where the SPA puts a - // NEW project when the user has saved no `projectRoot` of their own - // — `/projects` under Electron — and the host does not - // persist it into settings. Without it, the first template a user - // ever starts from is refused at its own suggested destination - // ("Studio doesn't show that folder as a project"), and the flow - // cannot bootstrap: `recentDirs` only learns a root once a session - // has been created there, and creation now happens FIRST. - ...(defaultProjectRoot ? [defaultProjectRoot] : []), - ...sessionManager.list().map((session) => session.cwd), - ], - workflowsCache.map((w) => w.path), - ); - }, - resolveAgent: (agentPath) => - workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null, - scaffoldAgent: async ({ targetDir, template }) => { - // `installDependencies: true` for the same reason the MCP tool passes - // it: the Canvas bundles the project on its first, unprompted render - // and resolves `@sapiom/agent`/`zod` from the project's own - // node_modules, so a never-installed agent opens on a "Could not - // resolve …" error. Best-effort inside agent-core — a failed install - // still returns a created project. - const result = await scaffold({ - targetDir, - template, - templatesDir: agentCoreTemplatesDir(), - installDependencies: true, - }); - return { dependenciesInstalled: result.dependenciesInstalled }; - }, - // Rescan the PROJECT root, not the agent directory: the registry has to - // learn the new agent under the project the rail draws it in, and the - // scan broadcasts `workflows.changed` so the row is there before the - // dialog's caller opens a session on it. - onScaffolded: async (agentDir) => { - await scanWorkflowsAndBroadcast(dirname(agentDir), "agent-created", { - dirty: true, - }); - }, - }), - ); + app.use(createAgentScaffoldRouter(scaffoldDeps)); app.use( createWorkflowsRouter(enrichedWorkflowRegistry), createFsRouter(), @@ -4177,6 +4260,11 @@ export const startServer = async ( } }, onEventPersisted: (event: AnalyticsEvent, runtimeEpoch) => { + void createdAgentRegistration + .onEventPersisted(event, runtimeEpoch) + .catch(() => { + console.error("[harness] created agent registration failed"); + }); void projectBootstrap!.onEventPersisted(event, runtimeEpoch).catch(() => { console.error("[harness] project bootstrap completion failed"); }); @@ -4234,6 +4322,8 @@ export const startServer = async ( runtimeEpoch, cwd: session.cwd, sinceMs: Number.isNaN(sinceMs) ? Date.now() : sinceMs, + ...(sessionManager.getSubsessionBinding(session.id) + ? { requiredRuntimeMarker: codexRuntimeMarker(runtimeEpoch) } : {}), }); if (claim.outcome === "claimed") return { path: claim.path, ambiguous: false }; @@ -4402,6 +4492,7 @@ export const startServer = async ( await settle(() => sessionManager.beginShutdown()); const bootstrapClosing = settle(() => projectBootstrap?.close()); + const registrationClosing = settle(() => createdAgentRegistration.close()); coordinatorActive = false; coordinatorEpoch += 1; clearInterval(sessionSweepTimer); @@ -4412,6 +4503,7 @@ export const startServer = async ( ); await settle(() => canvasWatcher.stopAll()); await settle(() => workspaceWatcher.stopAll()); + await settle(() => createdAgentWatcher.stopAll()); await settle(() => systemGraphWatcher.stopAll()); activeSystemGraphScopes.clear(); await settle(() => systemGraphInvocations.clear()); @@ -4444,6 +4536,7 @@ export const startServer = async ( // wait leaves the existing writes intact; it never reopens admission. const drainsSettled = (async () => { await bootstrapClosing; + await registrationClosing; await settle(() => sessionManager.flush()); await settle(async () => { await recordBackfill; @@ -4496,6 +4589,13 @@ export const startServer = async ( let actualPort = options.port; try { + // Before the first browser state read: restore creator ownership from the + // durable local completion stream, including pre-fix scaffolded siblings. + await createdAgentRegistration + .recover(sessionManager.list().map((session) => session.id)) + .catch(() => { + console.error("[harness] created agent recovery failed"); + }); await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(options.port, host, () => { diff --git a/packages/harness/src/server/rest.test.ts b/packages/harness/src/server/rest.test.ts index 4952d42bb..e89f8b790 100644 --- a/packages/harness/src/server/rest.test.ts +++ b/packages/harness/src/server/rest.test.ts @@ -542,6 +542,56 @@ describe("createRestRouter", () => { }); describe("POST /sessions", () => { + it("preserves the initial task and scaffold request without an input-route round trip", async () => { + const sessionManager = fakeSessionManager(); + vi.mocked(sessionManager.create).mockResolvedValue(exitedSession()); + start({ sessionManager }); + const request = { + cwd: "/tmp/proj", harness: "claude-code", + initialPrompt: "Build ticket triage.\nUse my files.", + scaffold: { template: "default" }, + initialAttachments: [{ kind: "path", path: "/tmp/brief.pdf" }], + }; + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request), + }); + expect(res.status).toBe(201); + expect(sessionManager.create).toHaveBeenCalledExactlyOnceWith(request); + expect(sessionManager.submitInput).not.toHaveBeenCalled(); + }); + + it.each([ + { initialPrompt: "bad\0argument" }, + { initialPrompt: "x".repeat(32_001) }, + { initialAttachments: [{ kind: "path", path: "bad\0path" }] }, + { initialAttachments: [{ kind: "inline", filename: "bad.png", dataUrl: "data:image/png;base64,invalid!" }] }, + { scaffold: { template: "../../escape" } }, + ])("rejects malformed first-turn input before session creation", async (input) => { + const sessionManager = fakeSessionManager(); + start({ sessionManager }); + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ cwd: "/tmp/proj", harness: "claude-code", ...input }), + }); + expect(res.status).toBe(400); + expect(sessionManager.create).not.toHaveBeenCalled(); + }); + + it("accepts multiple initial files above the individual-upload JSON limit", async () => { + const sessionManager = fakeSessionManager(); + vi.mocked(sessionManager.create).mockResolvedValue(exitedSession()); + start({ sessionManager }); + const dataUrl = `data:application/octet-stream;base64,${Buffer.alloc(6 * 1024 * 1024).toString("base64")}`; + const res = await fetch(`${baseUrl}/sessions`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + cwd: "/tmp/proj", harness: "codex", + initialAttachments: [1, 2].map((i) => ({ kind: "inline", filename: `file${i}.bin`, dataUrl })), + }), + }); + expect(res.status).toBe(201); + expect(sessionManager.create).toHaveBeenCalledOnce(); + }); it("calls onSessionCreated with the new session's cwd and id", async () => { const onSessionCreated = vi.fn(); @@ -868,6 +918,29 @@ describe("createRestRouter", () => { }); expect(limited.status).toBe(429); }); + + it("keeps session creation and successful attachment uploads in independent rate limits", async () => { + const attachment = { filename: "quota-proof.txt", dataUrl: "data:text/plain;base64,YQ==" }; + for (let index = 0; index < 30; index += 1) { + const uploaded = await postAttachment(attachment); + expect(uploaded.status).toBe(200); + } + expect((await postAttachment(attachment)).status).toBe(429); + expect(await fs.readdir(path.join(projectRoot, ".sapiom", "uploads"))).toHaveLength(30); + + vi.mocked(sessionManager.create).mockResolvedValue(exitedSession({ cwd: projectRoot })); + const createSession = () => fetch(`${baseUrl}/sessions`, { + method: "POST", + headers: { ...TOKEN_HEADER, "content-type": "application/json" }, + body: JSON.stringify({ cwd: projectRoot, harness: "claude-code", initialPrompt: "Start my next task." }), + }); + for (let index = 0; index < 30; index += 1) { + expect((await createSession()).status).toBe(201); + } + expect((await createSession()).status).toBe(429); + expect(sessionManager.create).toHaveBeenCalledTimes(30); + expect((await postAttachment(attachment)).status).toBe(429); + }); }); describe("POST /sessions/:id/input", () => { diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index acc5be74a..27940418a 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -1,6 +1,3 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { resolveWithinRoot } from "../core/path-safety.js"; /** * REST surface under /api — see src/shared/types.ts for the full contract * table. This router covers the session-lifecycle endpoints (W1); workflows, @@ -17,7 +14,6 @@ import type { AnalyticsEvent, AppState, AttachFileRequest, - AttachFileResponse, BackgroundTask, BindWorkflowRequest, CreateSessionRequest, @@ -37,8 +33,7 @@ import type { import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import type { StudioProjectSummary } from "../shared/agent-map.js"; import { - HARNESS_UPLOADS_DIR, - MAX_INLINE_ATTACHMENT_BYTES, + CREATE_SESSION_JSON_LIMIT_BYTES, JSON_BODY_LIMIT_BYTES, SPAWNABLE_HARNESS_KINDS, EDITOR_KINDS, @@ -66,43 +61,31 @@ import { getHarnessAdapter, listHarnessAdapters, } from "../core/adapters/registry.js"; +import { AttachmentError, writeAttachment } from "./attachments.js"; +import { ScaffoldError } from "./scaffold.js"; +import { validateInitialAttachments } from "./first-request.js"; import { loadSettings, saveSettings } from "../cli/settings.js"; - - -const DATA_URL_RE = /^data:([a-z0-9.+/-]+);base64,([\s\S]+)$/i; - - -/** Validate standard padded base64 in one pass and return decoded size. */ -function decodedBase64Size(encoded: string): number | null { - if (encoded.length === 0 || encoded.length % 4 !== 0) return null; - const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; - const contentLength = encoded.length - padding; - for (let index = 0; index < encoded.length; index += 1) { - const code = encoded.charCodeAt(index); - const isDataCharacter = - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) || - (code >= 48 && code <= 57) || - code === 43 || - code === 47; - if (index < contentLength ? !isDataCharacter : code !== 61) return null; - } - return (encoded.length / 4) * 3 - padding; -} - // Derived from SPAWNABLE_HARNESS_KINDS (shared/types.ts) so the zod // validation and the TypeScript type can never drift from each other. // Adding a new spawnable harness means updating that one constant; the // validator here and the HarnessKind type both pick up the change automatically. -const createSessionSchema = z.object({ - cwd: z.string().min(1), - harness: z.enum(SPAWNABLE_HARNESS_KINDS), - profile: z.string().optional(), - rehydrateFrom: z.string().min(1).optional(), - theme: z.enum(["light", "dark"]).optional(), - initialUserInputPending: z.boolean().optional(), -}).strict() satisfies z.ZodType; +const createSessionSchema = z + .object({ + cwd: z.string().min(1), + harness: z.enum(SPAWNABLE_HARNESS_KINDS), + profile: z.string().optional(), + initialPrompt: z.string().max(32_000).refine((text) => !text.includes("\0")).optional(), + initialAttachments: z.array(z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("path"), path: z.string().min(1).max(4096).refine((text) => !text.includes("\0")) }).strict(), + z.object({ kind: z.literal("inline"), dataUrl: z.string().min(1), filename: z.string().trim().min(1).max(255) }).strict(), + ])).max(100).optional(), + scaffold: z.object({ template: z.string().regex(/^[a-z0-9][a-z0-9-]*$/i) }).strict().optional(), + initialUserInputPending: z.boolean().optional(), + rehydrateFrom: z.string().min(1).optional(), + theme: z.enum(["light", "dark"]).optional(), + }) + .strict() satisfies z.ZodType; const injectInputSchema = z.object({ text: z.string(), @@ -346,12 +329,19 @@ export function createRestRouter(options: RestRouterOptions): Router { listMacros, } = options; const router = Router(); + const sessionCreationRateLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + }); const attachmentUploadRateLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, }); + router.post("/sessions", express.json({ limit: CREATE_SESSION_JSON_LIMIT_BYTES })); router.use(express.json({ limit: JSON_BODY_LIMIT_BYTES })); router.get("/state", async (_req, res, next) => { @@ -485,7 +475,7 @@ export function createRestRouter(options: RestRouterOptions): Router { } }); - router.post("/sessions", async (req, res, next) => { + router.post("/sessions", sessionCreationRateLimiter, async (req, res, next) => { const parsed = createSessionSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); @@ -498,10 +488,15 @@ export function createRestRouter(options: RestRouterOptions): Router { // sessionManager.create() writes the initial harness-context.json // itself (before spawning) so every entry point gets it, not just // this REST route — see SessionManager.create(). + validateInitialAttachments(request.initialAttachments); const session = await sessionManager.create(request); res.status(201).json(session); options.onSessionCreated?.(request.cwd, session.id); } catch (err) { + if (err instanceof ScaffoldError || err instanceof AttachmentError) { + res.status(err.status).json({ error: err.message }); + return; + } if ( err instanceof AdapterNotFoundError || err instanceof SpawnTargetError @@ -540,70 +535,13 @@ export function createRestRouter(options: RestRouterOptions): Router { return; } - const match = DATA_URL_RE.exec(parsed.data.dataUrl); - if (!match) { - res.status(400).json({ error: "dataUrl must be a base64 data: URL" }); - return; - } - - const mediaType = match[1]!.toLowerCase(); - const encoded = match[2]!; - const decodedSize = decodedBase64Size(encoded); - if (decodedSize === null) { - res - .status(400) - .json({ error: "attachment payload is not valid base64" }); - return; - } - if (decodedSize === 0) { - res.status(400).json({ error: "attachment payload is empty" }); - return; - } - if (decodedSize > MAX_INLINE_ATTACHMENT_BYTES) { - res.status(413).json({ - error: `Attachment is ${decodedSize} bytes; the limit is ${MAX_INLINE_ATTACHMENT_BYTES} bytes`, - }); - return; - } - const buffer = Buffer.from(encoded, "base64"); - - const uploadsDir = resolveWithinRoot(session.cwd, HARNESS_UPLOADS_DIR); - if (!uploadsDir) { - res - .status(500) - .json({ error: "could not resolve the uploads directory" }); - return; - } - const requestedExtension = path.extname( - path.basename(parsed.data.filename), - ); - const extension = /^\.[a-z0-9]{1,12}$/i.test(requestedExtension) - ? requestedExtension.toLowerCase() - : ".bin"; try { - await fs.mkdir(uploadsDir, { recursive: true }); - const [realCwd, realUploadsDir] = await Promise.all([ - fs.realpath(session.cwd), - fs.realpath(uploadsDir), - ]); - if (!resolveWithinRoot(realCwd, realUploadsDir)) { - res.status(400).json({ - error: "uploads directory escapes the session cwd", - }); + res.json(await writeAttachment(session.cwd, parsed.data)); + } catch (error) { + if (error instanceof AttachmentError) { + res.status(error.status).json({ error: error.message }); return; } - const filePath = path.join( - realUploadsDir, - `${randomUUID()}${extension}`, - ); - await fs.writeFile(filePath, buffer); - const response: AttachFileResponse = { - path: filePath, - mediaType, - bytes: buffer.byteLength, - }; - res.json(response); - } catch (error) { next(error); } }, diff --git a/packages/harness/src/server/scaffold.ts b/packages/harness/src/server/scaffold.ts index 2717e0f50..2279d86d4 100644 --- a/packages/harness/src/server/scaffold.ts +++ b/packages/harness/src/server/scaffold.ts @@ -217,133 +217,85 @@ async function removeFailedScaffold(dir: string): Promise { * 500 — the scaffold itself failed; the directory it may have created is * removed first, so the retry meets the same clean state the first attempt did. */ -export function createAgentScaffoldRouter( +export class ScaffoldError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +/** One guarded implementation for the agent dialog and first-run launch. */ +export async function scaffoldAgentProject( deps: AgentScaffoldDeps, -): ExpressRouter { + input: { root?: unknown; name?: unknown; template?: unknown }, +): Promise { + const { root, name } = input; + const template = input.template ?? "default"; + if (typeof root !== "string" || !path.isAbsolute(root) || hasTraversalSegment(root)) { + throw new ScaffoldError(400, "root must be an absolute path"); + } + const nameRefusal = refuseAgentName(name); + if (nameRefusal != null) throw new ScaffoldError(400, nameRefusal); + if (typeof template !== "string" || !TEMPLATE_NAME.test(template)) { + throw new ScaffoldError(400, `Unknown template '${String(template)}'.`); + } + const requested = path.resolve(root); + const projectDir = (await deps.listProjectDirs()).find( + (dir) => typeof dir === "string" && dir.trim() !== "" && samePath(dir, requested), + ); + if (projectDir == null) { + throw new ScaffoldError(409, `Can't create an agent in ${requested} — Studio doesn't show that folder as a project.`); + } + const projectLabel = path.basename(path.resolve(projectDir)) || projectDir; + const child = childPath(path.resolve(projectDir), name as string); + // Reassert containment at the filesystem sink, using the server's root. + const target = child == null ? null : resolveWithinRoot(path.resolve(projectDir), child); + if (target == null) { + throw new ScaffoldError(400, `'${String(name)}' isn't a folder name.`); + } + const existing = deps.resolveAgent(target); + if (existing != null) { + throw new ScaffoldError(409, `${projectLabel} already has an agent called ${existing.name}.`); + } + const diskRefusal = await refuseScaffoldOnDisk(target, projectLabel); + if (diskRefusal != null) throw new ScaffoldError(409, diskRefusal); + const claimRefusal = await claimTarget(target, path.resolve(projectDir), projectLabel); + if (claimRefusal != null) throw new ScaffoldError(409, claimRefusal); + + let result: { dependenciesInstalled: boolean }; + try { + result = await deps.scaffoldAgent({ targetDir: target, template }); + } catch (err) { + await removeFailedScaffold(target); + throw new ScaffoldError(500, (err as Error).message || `Couldn't create ${String(name)}.`); + } + await deps.onScaffolded(target); + return { + ok: true, + path: target, + name: path.basename(target), + template, + dependenciesInstalled: result.dependenciesInstalled, + }; +} + +export function createAgentScaffoldRouter(deps: AgentScaffoldDeps): ExpressRouter { const router = Router(); - /** - * A create is the most expensive request this server serves — a template - * copy, an `npm install` and a `git init` per call — so it is the one worth - * bounding. The window is far above anything a person clicking a dialog can - * reach; it exists so a stuck client cannot turn a create loop into a disk - * full of half-built projects. Same shape as the attachment-upload limiter - * in `server/rest.ts`. - */ const scaffoldRateLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, }); - router.post("/api/agents/scaffold", scaffoldRateLimiter, async (req, res, next) => { - const body = (req.body ?? {}) as { - root?: unknown; - name?: unknown; - template?: unknown; - }; - const { root, name } = body; - const template = body.template ?? "default"; - - if (typeof root !== "string" || !path.isAbsolute(root) || hasTraversalSegment(root)) { - res.status(400).json({ error: "root must be an absolute path" }); - return; - } - const nameRefusal = refuseAgentName(name); - if (nameRefusal != null) { - res.status(400).json({ error: nameRefusal }); - return; - } - if (typeof template !== "string" || !TEMPLATE_NAME.test(template)) { - res.status(400).json({ error: `Unknown template '${String(template)}'.` }); - return; - } - try { - // THE DESTINATION BARRIER, identical in shape to the move route's: the - // requested root is matched against the directories the rail can show, - // and the DIRECTORY FROM THAT LIST is what the scaffold writes into. - const requested = path.resolve(root); - const projectDir = (await deps.listProjectDirs()).find( - (dir) => - typeof dir === "string" && dir.trim() !== "" && samePath(dir, requested), - ); - if (projectDir == null) { - res.status(409).json({ - error: `Can't create an agent in ${requested} — Studio doesn't show that folder as a project.`, - }); - return; - } - const projectLabel = path.basename(path.resolve(projectDir)) || projectDir; - // THE JOIN, on `path.resolve` of the LIST's entry rather than the - // request's. `childPath` is the rule: one plain child of this project, - // nothing else — it is what refuses every escaping name, and the - // co-located test proves that by posting them. - // - // `resolveWithinRoot` is a SINK-LOCAL RE-ASSERTION on top of it, and - // deliberately unreachable: nothing `childPath` returns can fail it - // today, so no test can make it fire (stubbing it out leaves the suite - // green — said plainly rather than dressed up as a second guard). It - // earns its place twice over anyway: it is the containment check in the - // form static analysis recognizes — CodeQL reads `childPath`'s - // `dirname(...) === root` comparison as no barrier at all and flags every - // `fs` call below as path injection — and it survives a reordering of the - // guards above it, which is exactly the edit that would make the rule - // reachable again. Same shape `server/canvas.ts` uses for its - // user-supplied sub-path. - const child = childPath(path.resolve(projectDir), name as string); - const target = - child == null ? null : resolveWithinRoot(path.resolve(projectDir), child); - if (target == null) { - res.status(400).json({ error: `'${String(name)}' isn't a folder name.` }); - return; - } - - const existing = deps.resolveAgent(target); - if (existing != null) { - res.status(409).json({ - error: `${projectLabel} already has an agent called ${existing.name}.`, - }); - return; - } - const diskRefusal = await refuseScaffoldOnDisk(target, projectLabel); - if (diskRefusal != null) { - res.status(409).json({ error: diskRefusal }); - return; - } - // THE CLAIM, and the last word on who owns this name. Everything above is - // a reason to refuse early; this is the one act that cannot be raced. - const claimRefusal = await claimTarget(target, path.resolve(projectDir), projectLabel); - if (claimRefusal != null) { - res.status(409).json({ error: claimRefusal }); - return; - } - - let result: { dependenciesInstalled: boolean }; - try { - result = await deps.scaffoldAgent({ targetDir: target, template }); - } catch (err) { - await removeFailedScaffold(target); - res.status(500).json({ - error: (err as Error).message || `Couldn't create ${String(name)}.`, - }); + res.json(await scaffoldAgentProject(deps, req.body ?? {})); + } catch (err) { + if (err instanceof ScaffoldError) { + res.status(err.status).json({ error: err.message }); return; } - - // Before the response, deliberately: the agent is in the rail by the time - // the caller can act on the result. - await deps.onScaffolded(target); - res.json({ - ok: true, - path: target, - name: path.basename(target), - template, - dependenciesInstalled: result.dependenciesInstalled, - } satisfies AgentScaffoldResponse); - } catch (err) { next(err); } }); - return router; } diff --git a/packages/harness/src/server/served-system-prompt.test.ts b/packages/harness/src/server/served-system-prompt.test.ts index 9328744f6..57208077a 100644 --- a/packages/harness/src/server/served-system-prompt.test.ts +++ b/packages/harness/src/server/served-system-prompt.test.ts @@ -14,6 +14,7 @@ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { FocusedSessionContextProjection } from "../core/focused-session-context.js"; import { startServer, type HarnessServer } from "./index.js"; import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; @@ -76,7 +77,7 @@ describe("served system prompt reaches the launched session", () => { bootToken: "test-token", telemetryOptIn: false, autoCreateSession: false, - adapters: { "claude-code": fakeAdapter("claude-code") }, + adapters: { "claude-code": fakeAdapter("claude-code"), codex: fakeAdapter("codex") }, stateRoot: dir, loadSystemPrompt, }); @@ -91,7 +92,42 @@ describe("served system prompt reaches the launched session", () => { const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); - expect(await systemPromptFile(session.id)).toBe(`${SERVED_PROMPT}\n\n${PROJECT_AGENT_PROMPT_APPENDIX}\n`); + const prompt = await systemPromptFile(session.id); + expect(prompt).toContain(SERVED_PROMPT); + expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); + }); + + it.each(["claude-code", "codex"])( + "upgrades the known stale served orientation at the %s launch boundary", + async (harness) => { + const legacy = (await readFile(new URL("../profiles/fixtures/legacy-system-prompt.md", import.meta.url), "utf8")).trim(); + server = await boot(async () => legacy); + const session = await server.sessionManager.create({ cwd, harness }); + const prompt = await systemPromptFile(session.id); + expect(prompt).toContain(DEFAULT_SYSTEM_PROMPT); + expect(prompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); + expect(prompt).not.toContain("two Sapiom MCP servers"); + expect(prompt).not.toContain("then stop"); + expect(prompt).toContain("ctx.sapiom.llm.run"); + expect(prompt).toContain("sapiom_dev_agents_*"); + expect(prompt).toContain("build_plan_rebase"); + }, + ); + + it("adds an optional focused overlay after the unchanged common project prompt", async () => { + server = await boot(async () => SERVED_PROMPT); + const focused = ( + `\n{}\n` + ) as FocusedSessionContextProjection; + const session = await server.sessionManager.create( + { cwd, harness: "claude-code" }, + { focusedContext: () => focused }, + ); + + const prompt = await systemPromptFile(session.id); + expect(prompt.match(//gu)).toHaveLength(1); + expect(prompt.indexOf(PROJECT_AGENT_PROMPT_APPENDIX)).toBeLessThan(prompt.indexOf(focused)); + expect(prompt).toContain(focused); }); it("re-reads it on resume, so a redeployed prompt reaches a continued session", async () => { diff --git a/packages/harness/src/shared/initial-prompt.ts b/packages/harness/src/shared/initial-prompt.ts new file mode 100644 index 000000000..b603d0d16 --- /dev/null +++ b/packages/harness/src/shared/initial-prompt.ts @@ -0,0 +1,18 @@ +/** Paths are context, never shell commands. Match native terminal drop quoting. */ +export function quotePathForTerminal(path: string): string { + if (/^[A-Za-z0-9_\-./~:\\]+$/.test(path)) return path; + if (/^[A-Za-z]:[\\/]/.test(path)) return `"${path}"`; + return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function buildIdeaWithAttachments( + idea: string, + attachments: readonly { path: string; name?: string }[], +): string | undefined { + const trimmed = idea.trim(); + if (attachments.length === 0) return trimmed || undefined; + const context = + "Attached files (read each as context):\n" + + attachments.map(({ path }) => quotePathForTerminal(path)).join("\n"); + return trimmed ? `${trimmed}\n\n${context}` : context; +} diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index dfe17eed7..9ec832835 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -104,6 +104,11 @@ export const MAX_INLINE_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024; */ export const JSON_BODY_LIMIT_BYTES = 15 * 1024 * 1024; +/** First-turn uploads arrive together, bounded by the composer's 50 MiB cap. + * Other endpoints keep the smaller JSON limit. */ +export const CREATE_SESSION_JSON_LIMIT_BYTES = + Math.ceil(MAX_INLINE_ATTACHMENTS_TOTAL_BYTES * 4 / 3) + 1024 * 1024; + /** * Workspace-state convention: Agent Studio mirrors this session's binding, * the full agent registry, and its own identity here, relative to the @@ -214,10 +219,10 @@ export interface HarnessSession { * answer the blocking prompt themselves. */ ready: boolean; + /** Durable lifecycle state for a new project's one automatic map seed. */ + projectBootstrap?: import("./agent-map.js").ProjectBootstrapMetadata; /** Server-authored, path-free identity used only to revalidate MCP scope. */ agentMapIdentity?: import("./agent-map.js").ProjectAgentSession; - /** Context for the existing project startup; separate from authority. */ - projectBootstrap?: import("./agent-map.js").ProjectBootstrapMetadata; } /** @@ -326,6 +331,9 @@ export interface LaunchOpts { * --plugin-dir (e.g. codex) silently ignore this field. */ pluginDir?: string; + /** User-authored first turn, passed to the interactive CLI at fresh launch. + * Never persisted in session metadata or replayed by resume. */ + initialPrompt?: string; /** Only consulted by `launchTask` — the one-shot prompt a headless * background task runs, then exits. Unused by `launch`/`resume`. */ prompt?: string; @@ -1102,13 +1110,26 @@ export interface AgentScaffoldResponse { export type UiTheme = "light" | "dark"; export interface CreateSessionRequest { - /** An explicit UI action already owns the first user input. Bootstrap consumes - * this hint when activated; it never contributes session authority. */ - initialUserInputPending?: boolean; cwd: string; harness: HarnessKind; /** Profile id; omit for default. */ profile?: string; + /** First user turn. The CLI owns delivery after its trust/login screens. */ + initialPrompt?: string; + /** Files to materialize before spawning, in the user's selected order. */ + initialAttachments?: Array< + | { kind: "path"; path: string } + | ({ kind: "inline" } & AttachFileRequest) + >; + /** Create a new project at cwd using the same guarded scaffold as agent +. + * Omitted for sessions in existing projects. */ + scaffold?: { template: string }; + /** + * Content-free lifecycle hint: the UI already owns a real first input that + * will be delivered after readiness/attachments. A new-project bootstrap + * yields to that input instead of racing it. This never affects authority. + */ + initialUserInputPending?: boolean; /** * Portable continue: seed this fresh session with a reconstruction of a * prior one instead of asking the vendor to reattach. Accepts either a @@ -1184,6 +1205,20 @@ export interface InjectInputRequest { requestId?: string; } +export interface InjectInputResponse { + ok: true; + /** Present only when the durable bootstrap FIFO handled this request. */ + receipt?: import("./agent-map.js").ProjectBootstrapInputReceipt; +} + +/** Internal server boundary shared by the canonical route and rolling alias. */ +export type SessionInputSubmissionResult = + | { ok: false } + | { + ok: true; + receipt?: import("./agent-map.js").ProjectBootstrapInputReceipt; + }; + /** `PATCH /api/sessions/:id/workflow` body. `null` unbinds. `workflowPath` * must be a path already known to the workflow registry (scan/connect). */ export interface BindWorkflowRequest { @@ -1841,16 +1876,3 @@ export interface StudioRailLaunchEdge { export interface StudioRailLaunchEdgesResponse { edges: StudioRailLaunchEdge[]; } - -export interface InjectInputResponse { - ok: true; - /** Present only when the durable bootstrap FIFO handled this request. */ - receipt?: import("./agent-map.js").ProjectBootstrapInputReceipt; -} - -export type SessionInputSubmissionResult = - | { ok: false } - | { - ok: true; - receipt?: import("./agent-map.js").ProjectBootstrapInputReceipt; - }; diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 51547410f..4f4f6d3c5 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -13,6 +13,9 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@shared/initial-prompt": fileURLToPath( + new URL("src/shared/initial-prompt.ts", import.meta.url), + ), // Resolve "@shared/types" to the package's canonical contract so web // unit tests and server tests always build against the same source of // truth. Mirrors the alias in web/vite.config.ts. diff --git a/packages/harness/web/e2e/new-session-composer.spec.ts b/packages/harness/web/e2e/new-session-composer.spec.ts index 4994aac31..0563f0aa9 100644 --- a/packages/harness/web/e2e/new-session-composer.spec.ts +++ b/packages/harness/web/e2e/new-session-composer.spec.ts @@ -11,14 +11,14 @@ import type { Page } from "@playwright/test"; import { selectMockSessionFromPalette } from "./mock-navigation"; -const lastInjectText = (page: Page): Promise => +const initialTaskText = (page: Page): Promise => page.evaluate( () => ( window as unknown as { - __HARNESS_TEST__?: { lastInjectInput?: { req?: { text?: string } } }; + __HARNESS_TEST__?: { lastInitialInput?: { text?: string } }; } - ).__HARNESS_TEST__?.lastInjectInput?.req?.text ?? "", + ).__HARNESS_TEST__?.lastInitialInput?.text ?? "", ); const injectCallCount = (page: Page): Promise => @@ -37,8 +37,8 @@ const sessionEvidence = ( activeSessionId: string | null; createSessionCalls: number; injectInputCalls: number; - injectedSessionId: string | null; - injectedText: string; + initialSessionId: string | null; + initialText: string; }> => page.evaluate(() => { const testState = ( @@ -46,7 +46,7 @@ const sessionEvidence = ( __HARNESS_TEST__?: { createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; - lastInjectInput?: { id?: string; req?: { text?: string } }; + lastInitialInput?: { id?: string; text?: string }; }; } ).__HARNESS_TEST__; @@ -57,8 +57,8 @@ const sessionEvidence = ( ?.getAttribute("data-session-id") || null, createSessionCalls: testState?.createSessionCalls?.length ?? 0, injectInputCalls: testState?.injectInputCalls?.length ?? 0, - injectedSessionId: testState?.lastInjectInput?.id ?? null, - injectedText: testState?.lastInjectInput?.req?.text ?? "", + initialSessionId: testState?.lastInitialInput?.id ?? null, + initialText: testState?.lastInitialInput?.text ?? "", }; }); @@ -98,10 +98,11 @@ test("describing an outcome starts a session and hands the agent that outcome", await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect(page.getByTestId("agent-view")).toBeVisible(); - // The typed outcome rode into the scaffold prompt handed to the agent. + // The exact user task is a launch argument, without a scaffold wrapper. await expect - .poll(() => lastInjectText(page)) - .toContain("Diff our competitors' pricing pages"); + .poll(() => initialTaskText(page)) + .toBe("Diff our competitors' pricing pages every morning."); + expect(await injectCallCount(page)).toBe(0); }); test("Enter keeps a new-agent prompt in its exact session while the project map is inspected", async ({ @@ -164,16 +165,16 @@ test("Enter keeps a new-agent prompt in its exact session while the project map await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect - .poll(async () => (await sessionEvidence(page)).injectedText) + .poll(async () => (await sessionEvidence(page)).initialText) .toContain(idea); const evidence = await sessionEvidence(page); expect(evidence.createSessionCalls).toBe(before.createSessionCalls + 1); - expect(evidence.injectedSessionId).not.toBeNull(); - expect(evidence.injectedSessionId).not.toBe("sess-competing-plan-agents"); - expect(evidence.activeSessionId).toBe(evidence.injectedSessionId); + expect(evidence.initialSessionId).not.toBeNull(); + expect(evidence.initialSessionId).not.toBe("sess-competing-plan-agents"); + expect(evidence.activeSessionId).toBe(evidence.initialSessionId); expect(evidence.activeSessionId).not.toBe(before.activeSessionId); - expect(evidence.injectInputCalls).toBe(before.injectInputCalls + 1); + expect(evidence.injectInputCalls).toBe(before.injectInputCalls); expect( await page.evaluate( () => @@ -242,7 +243,7 @@ test("returning to an in-progress standalone session does not restore the projec ); const awaySessionId = (await sessionEvidence(page)).activeSessionId!; await expect - .poll(async () => (await sessionEvidence(page)).injectedText) + .poll(async () => (await sessionEvidence(page)).initialText) .toContain(idea); // Let the session we deliberately visited finish its own normal restore; // only map restoration caused by returning to the explicit session is under @@ -290,7 +291,7 @@ test("a picked file reaches the first request without naming the project", async await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain('"/Users/test/My Files/requirements.pdf"'); const createRequest = await page.evaluate( @@ -389,21 +390,21 @@ test("picker, drop, and pathless clipboard files reach one ordered first request await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("mock-screenshot.png"); const proof = await page.evaluate(() => { const testState = ( window as unknown as { __HARNESS_TEST__?: { attachFileCalls?: unknown[]; - lastInjectInput?: { req?: { text?: string } }; + lastInitialInput?: { text?: string }; lastCreateSession?: { req?: { cwd?: string } }; }; } ).__HARNESS_TEST__; return { calls: testState?.attachFileCalls ?? [], - text: testState?.lastInjectInput?.req?.text ?? "", + text: testState?.lastInitialInput?.text ?? "", cwd: testState?.lastCreateSession?.req?.cwd ?? "", }; }); @@ -554,9 +555,9 @@ test("re-adding and removing files keeps only the intended first-request paths", await page.getByTestId("composer-input").fill("Use selected context."); await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("/Users/test/keep.pdf"); - expect(await lastInjectText(page)).not.toContain("remove.txt"); + expect(await initialTaskText(page)).not.toContain("remove.txt"); }); test("an attachment-only start uses the fallback project and sends the file", async ({ @@ -578,7 +579,7 @@ test("an attachment-only start uses the fallback project and sends the file", as await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("/Users/test/brief.pdf"); const cwd = await page.evaluate( () => @@ -662,11 +663,11 @@ test("an upload failure rolls back, retains the queue, sends nothing, and retrie injected: state?.lastInjectInput != null, }; }); - expect(failedProof).toEqual({ creates: 1, kills: 1, injected: false }); + expect(failedProof).toEqual({ creates: 1, kills: 0, injected: false }); await page.getByTestId("composer-send").click(); await expect - .poll(() => lastInjectText(page)) + .poll(() => initialTaskText(page)) .toContain("mock-retry-screenshot.png"); const createCount = await page.evaluate( () => @@ -683,7 +684,7 @@ for (const agent of [ { id: "claude-code", label: "Claude Code" }, { id: "codex", label: "Codex" }, ] as const) { - test(`holds the prompt until ${agent.label} is ready, then sends it exactly once`, async ({ + test(`passes the first task at ${agent.label} launch without pasting before or after readiness`, async ({ page, }) => { // Make the next session never reach ready on its own — the stand-in for a @@ -711,7 +712,7 @@ for (const agent of [ // The session exists (workbench shown) but the prompt is HELD, not // injected, because the session never became ready. await expect(page.getByTestId("agent-view")).toBeVisible(); - expect(await lastInjectText(page)).toBe(""); + expect(await initialTaskText(page)).toBe(prompt); expect(await injectCallCount(page)).toBe(0); const createdHarness = await page.evaluate( () => @@ -725,16 +726,8 @@ for (const agent of [ ); expect(createdHarness).toBe(agent.id); - // The provider-neutral hint points at terminal setup while preserving the - // original prompt. - await expect(page.getByTestId("toast")).toContainText( - /signing in or dismiss any trust or setup prompt/i, - { timeout: 8_000 }, - ); - expect(await lastInjectText(page)).toBe(""); - - // A readiness status releases the prompt. Repeating the status event must - // not inject the held intent a second time. + // The native CLI, not a browser readiness timer, owns the pending task. + // Repeated readiness notifications must never paste or re-submit it. await page.evaluate(() => ( window as unknown as { @@ -742,8 +735,8 @@ for (const agent of [ } ).__HARNESS_TEST__?.promoteReady?.(), ); - await expect.poll(() => lastInjectText(page)).toContain(prompt); - await expect.poll(() => injectCallCount(page)).toBe(1); + await expect.poll(() => initialTaskText(page)).toContain(prompt); + await expect.poll(() => injectCallCount(page)).toBe(0); await page.evaluate(() => ( @@ -753,7 +746,7 @@ for (const agent of [ ).__HARNESS_TEST__?.promoteReady?.(), ); await page.waitForTimeout(500); - expect(await injectCallCount(page)).toBe(1); + expect(await injectCallCount(page)).toBe(0); }); } diff --git a/packages/harness/web/e2e/project-map-navigation.spec.ts b/packages/harness/web/e2e/project-map-navigation.spec.ts index eee3442b3..efd75160f 100644 --- a/packages/harness/web/e2e/project-map-navigation.spec.ts +++ b/packages/harness/web/e2e/project-map-navigation.spec.ts @@ -19,6 +19,7 @@ interface NavigationEvidence { activeSessionId: string | null; createSessionCalls: number; injectInputCalls: number; + resumeSessionCalls: number; } async function navigationEvidence(page: Page): Promise { @@ -29,6 +30,7 @@ async function navigationEvidence(page: Page): Promise { __HARNESS_TEST__?: { createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; + resumeSessionCalls?: unknown[]; }; } ).__HARNESS_TEST__; @@ -36,14 +38,171 @@ async function navigationEvidence(page: Page): Promise { activeSessionId: activeSession, createSessionCalls: state?.createSessionCalls?.length ?? 0, injectInputCalls: state?.injectInputCalls?.length ?? 0, + resumeSessionCalls: state?.resumeSessionCalls?.length ?? 0, }; }, active); } test.describe("SAP-3148 project Agent Map navigation", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/?seed=0&mockFixtures=deep&mockStudioProjects=present"); + test.beforeEach(async ({ page }, testInfo) => { + const sibling = testInfo.title.startsWith("a scaffolded sibling") + ? "&mockCreatedSibling=1" + : ""; + const empty = testInfo.title.includes("without a live conversation") + ? "&mockNoLiveSessions=1" + : ""; + const restored = testInfo.title.includes("after restart") + ? "&mockRestoredSessions=1" + : ""; + await page.goto( + `/?seed=0&mockFixtures=deep&mockStudioProjects=present${sibling}${empty}${restored}`, + ); + await expect(page.locator(".rail-workflows")).toBeVisible(); + }); + + test("a scaffolded sibling stays under its creating project and keeps the same conversation", async ({ + page, + }) => { + const project = page.getByTestId("workspace-group-acme-app"); + const sibling = project.getByTestId("workflow-report-reviewer"); + await expect(sibling).toBeVisible(); + await expect(page.getByTestId("workflow-report-reviewer")).toHaveCount(1); + await expect( + page.getByTestId("workspace-group-report-reviewer"), + ).toHaveCount(0); + const before = await navigationEvidence(page); + await sibling.click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + await openProjectMap(page, "acme-app"); + await sibling.click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + await page.reload(); + await expect(project.getByTestId("workflow-report-reviewer")).toBeVisible(); + await sibling.click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + await page.getByTestId("history-trigger").click(); + await page.getByTestId("filing-group-by").selectOption("group"); + await page.keyboard.press("Escape"); + await expect(project.getByTestId("workflow-report-reviewer")).toBeVisible(); + await project.getByTestId("workflow-report-reviewer").click(); + expect(await navigationEvidence(page)).toEqual(before); + }); + + for (const [harness, sessionId] of [ + ["Claude Code", "sess-leasing"], + ["Codex", "sess-leasing-2"], + ]) { + test(`a scaffolded sibling keeps its restored ${harness} conversation after restart`, async ({ + page, + }) => { + await page.getByTestId("history-trigger").click(); + await page.getByTestId("past-sessions-trigger").hover(); + await page.getByTestId(`exited-session-${sessionId}`).click(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + sessionId, + ); + await expect(page.getByTestId("dead-session-detail")).toContainText(harness); + const before = await navigationEvidence(page); + expect(before).toEqual({ + activeSessionId: sessionId, + createSessionCalls: 0, + injectInputCalls: 0, + resumeSessionCalls: 0, + }); + + const sibling = page + .getByTestId("workspace-group-acme-app") + .getByTestId("workflow-report-reviewer"); + await sibling.click(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + sessionId, + ); + await expect(page.getByTestId("dead-session-pane")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual(before); + + await page.reload(); + await expect(sibling).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + sessionId, + ); + await expect(page.getByTestId("dead-session-detail")).toContainText(harness); + await sibling.click(); + await expect(page.getByTestId("dead-session-pane")).toBeVisible(); + await expect(page.locator(".harness-terminal .xterm")).toHaveCount(0); + expect(await navigationEvidence(page)).toEqual(before); + }); + } + + test("a stale saved conversation falls back to the existing live session", async ({ + page, + }) => { + await page.evaluate(() => { + const key = "sapiom-harness-ui-prefs"; + const prefs = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem( + key, + JSON.stringify({ ...prefs, activeSessionId: "removed-session" }), + ); + }); + await page.reload(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + "sess-boot", + ); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + expect(await navigationEvidence(page)).toEqual({ + activeSessionId: "sess-boot", + createSessionCalls: 0, + injectInputCalls: 0, + resumeSessionCalls: 0, + }); + }); + + test("a scaffolded sibling without a live conversation starts at its original project root", async ({ + page, + }) => { + await page + .getByTestId("workspace-group-acme-app") + .getByTestId("workflow-report-reviewer") + .click(); + await page.getByTestId("open-agent-start-session").click(); + await expect(page.locator(".harness-terminal .xterm")).toBeVisible(); + const calls = await page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { + createSessionCalls?: Array<{ req: { cwd: string } }>; + }; + } + ).__HARNESS_TEST__?.createSessionCalls ?? [], + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.req.cwd).toBe("/Users/demo/acme-app"); + await expect( + page.getByTestId("workspace-group-report-reviewer"), + ).toHaveCount(0); + }); + + test("a scaffolded sibling is hidden with its closed project", async ({ + page, + }) => { + const project = page.getByTestId("workspace-group-acme-app"); + await expect(project.getByTestId("workflow-report-reviewer")).toBeVisible(); + await project.getByTestId("project-menu-acme-app").click(); + await page.getByTestId("project-remove-acme-app").click(); + await page.getByTestId("remove-project-confirm-btn").click(); + await expect(project).toHaveCount(0); + await expect(page.getByTestId("workflow-report-reviewer")).toHaveCount(0); + await page.reload(); await expect(page.locator(".rail-workflows")).toBeVisible(); + await expect(page.getByTestId("workflow-report-reviewer")).toHaveCount(0); }); test("the project name opens the durable map without touching its active conversation", async ({ diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index eeeef41a1..3720207ef 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -50,6 +50,7 @@ import { import type { JSX } from "react"; import type { AppState, + CreateSessionRequest, HarnessKind, HarnessSession, MacroDef, @@ -121,7 +122,7 @@ import { secretsDisabledReason, type ProjectRef, } from "./lib/canvas-altitude"; -import { mostSpecificStudioScope } from "./lib/agent-map"; +import { mostSpecificStudioScope, studioScopeForAgent } from "./lib/agent-map"; import { inputContractFromCanvasGraph } from "./lib/run-input"; import { agentUrl } from "./lib/urls"; import { @@ -159,8 +160,6 @@ import { type NavigationVisit, } from "./lib/navigation-history"; import { - buildIdeaWithAttachments, - materializeAttachments, type NewSessionAttachment, } from "./lib/new-session-attachments"; import { @@ -251,6 +250,9 @@ const HELD_PROMPT_TIMEOUT_MS = 10 * 60_000; const HELD_PROMPT_HINT_DELAY_MS = 4_000; interface CreateSessionAtOptions { + initialPrompt?: CreateSessionRequest["initialPrompt"]; + initialAttachments?: CreateSessionRequest["initialAttachments"]; + scaffold?: CreateSessionRequest["scaffold"]; /** Keep the create-new queue mounted while inline files are materialized. */ keepComposerOpen?: boolean; /** Keep an explicit new-agent builder active when its root joins Studio. */ @@ -1195,8 +1197,8 @@ export const App = (): JSX.Element => { ); const scope = workflow && state?.studioProjects - ? mostSpecificStudioScope( - workflow.path, + ? studioScopeForAgent( + workflow, state.workspaceScopes ?? [], state.studioProjects, ) @@ -1340,11 +1342,12 @@ export const App = (): JSX.Element => { (candidate) => candidate.projectId === projectId, ); if (!project) return null; - return mostSpecificStudioScope( - path, - workspaceScopes.filter((scope) => scope.projectId === projectId), - [project], + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, path), ); + return workflow + ? studioScopeForAgent(workflow, workspaceScopes, [project], projectId) + : null; }; const selectedStudioProject = effectiveStudioSelection ? (state.studioProjects?.find( @@ -1368,15 +1371,21 @@ export const App = (): JSX.Element => { : []; const selectedStudioScope = effectiveStudioSelection && selectedStudioProject - ? mostSpecificStudioScope( - selectedStudioWorkflow?.path ?? + ? selectedStudioWorkflow + ? studioScopeForAgent( + selectedStudioWorkflow, + selectedStudioScopes, + [selectedStudioProject], + selectedStudioProject.projectId, + ) + : mostSpecificStudioScope( focusedAgentPath ?? - activeSession?.cwd ?? - selectedStudioScopes[0]?.cwd ?? - "", - selectedStudioScopes, - [selectedStudioProject], - ) + activeSession?.cwd ?? + selectedStudioScopes[0]?.cwd ?? + "", + selectedStudioScopes, + [selectedStudioProject], + ) : null; const planFirstSelection = selectedStudioScope ? effectiveStudioSelection @@ -1747,8 +1756,22 @@ export const App = (): JSX.Element => { * do not: that folder is the new project's root by construction, and * resolving it upward would drop the new agent into its parent project. */ - const sessionCwdForAgent = (agentPath: string): string => - projectRootForAgent(agentPath, knownProjectRoots()); + const sessionCwdForAgent = (agentPath: string): string => { + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, agentPath), + ); + const scope = workflow + ? studioScopeForAgent( + workflow, + workspaceScopes, + state.studioProjects ?? [], + effectiveStudioSelection?.kind === "agent" + ? effectiveStudioSelection.projectId + : undefined, + ) + : null; + return scope?.cwd ?? projectRootForAgent(agentPath, knownProjectRoots()); + }; // The ONE choke point for session creation: sets the focus to the new // session's folder (so the main panel shows it) and fires telemetry once. @@ -1787,6 +1810,9 @@ export const App = (): JSX.Element => { { cwd, harness: agentHarness, + ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), + ...(options.initialAttachments?.length ? { initialAttachments: options.initialAttachments } : {}), + ...(options.scaffold ? { scaffold: options.scaffold } : {}), ...((options.initialUserInputPending ?? options.standaloneBuilder) ? { initialUserInputPending: true } : {}), @@ -2187,33 +2213,20 @@ export const App = (): JSX.Element => { } // Terminal-first: the new session's canvas slides in once it paints. setRightCollapsed(true); - const session = await createSessionAt(cwd, agentHarness, { + await createSessionAt(cwd, agentHarness, { keepComposerOpen: true, standaloneBuilder: true, + scaffold: { template: "default" }, + initialPrompt: idea.trim(), + initialAttachments: attachments.map((attachment) => + attachment.kind === "path" + ? { kind: "path", path: attachment.path } + : { kind: "inline", filename: attachment.name, dataUrl: attachment.dataUrl }, + ), }); - try { - const resolved = await materializeAttachments( - session.id, - attachments, - harness.attachFile, - ); - sendScaffoldPrompt( - session, - cwd, - buildIdeaWithAttachments(idea, resolved), - ); - setComposing(false); - } catch (error) { - // The first prompt is registered only after every upload succeeds. Kill - // the provisional session on failure so retrying reuses the same folder - // and queue instead of leaving a blank tab behind. - await harness.closeSession(session.id).catch((rollbackError: unknown) => { - console.error("[harness] attachment rollback failed:", rollbackError); - }); - pendingStandaloneBuilderSessionsRef.current.delete(cwd); - harness.removePendingWorkspace(cwd); - throw error; - } + // Only a successful create clears the draft. Scaffolding and uploads + // complete server-side before the vendor receives its first user turn. + setComposing(false); }; const handleComposerUseTemplate = (template: GalleryTemplate): void => { @@ -2358,8 +2371,8 @@ export const App = (): JSX.Element => { preferred?: { projectId: string; agentId: string }, ): { projectId: string; agentId: string } | null => { const bindings = workflow.studioBindings ?? []; - const owningScope = mostSpecificStudioScope( - workflow.path, + const owningScope = studioScopeForAgent( + workflow, workspaceScopes, state.studioProjects ?? [], ); @@ -2543,7 +2556,9 @@ export const App = (): JSX.Element => { const inferredScope = selectedBinding || !state.studioProjects ? null - : mostSpecificStudioScope(path, workspaceScopes, state.studioProjects); + : workflow + ? studioScopeForAgent(workflow, workspaceScopes, state.studioProjects) + : null; const targetBinding = selectedBinding ?? workflow?.studioBindings?.find( @@ -2553,7 +2568,9 @@ export const App = (): JSX.Element => { const targetScope = studioScopeForAgentProject(path, targetProjectId); const live = state.sessions.filter((s) => s.status !== "exited"); const ownsPath = (s: HarnessSession): boolean => - (samePath(s.boundWorkflowPath ?? "", path) || isWithinDir(s.cwd, path)) && + (Boolean(targetProjectId) || + samePath(s.boundWorkflowPath ?? "", path) || + isWithinDir(s.cwd, path)) && sessionReachesFocus( s, path, diff --git a/packages/harness/web/src/components/NewSessionComposer.tsx b/packages/harness/web/src/components/NewSessionComposer.tsx index 50b957bec..a56eea89d 100644 --- a/packages/harness/web/src/components/NewSessionComposer.tsx +++ b/packages/harness/web/src/components/NewSessionComposer.tsx @@ -7,7 +7,7 @@ import { type TemplateListResponse, } from "@shared/types"; -import type { FsListResponse } from "../lib/api"; +import { errorMessage, type FsListResponse } from "../lib/api"; import { FALLBACK_HARNESSES, harnessLabel, @@ -225,8 +225,7 @@ export function NewSessionComposer({ setSubmitting(false); textareaRef.current?.focus(); onAttachmentError( - (err as Error).message || - "Couldn't start a session with those files.", + errorMessage(err, "Couldn't start the coding session."), ); }, ); diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index 26e55a25f..c5387970e 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -69,6 +69,7 @@ import { import { loadUiPrefs, saveUiPrefs } from "../lib/ui-prefs"; import { agentPrefixes, + agentBelongsToProjectRoot, buildProjectTree, projectIsEmpty, projectRoots, @@ -694,7 +695,21 @@ export function WorkflowsRail({ const openRoots = [...recentDirs, ...pendingCwds]; const shown = (path: string): boolean => !hiddenByClosedProject(path, closedProjects, openRoots); - const visibleWorkflows = workflows.filter((workflow) => shown(workflow.path)); + const visibleWorkflows = workflows.filter((workflow) => { + const owners = (workspaceScopes ?? []).filter((scope) => + workflow.studioBindings?.some( + (binding) => binding.projectId === scope.projectId, + ), + ); + // Closing a project also hides its logically associated sibling folders. + // Its durable identity may outlive its final published/open root. + if (owners.length === 0 && workflow.studioBindings?.some((binding) => + studioProjects?.some((project) => project.projectId === binding.projectId), + )) return false; + return owners.length > 0 + ? owners.some((scope) => shown(scope.cwd)) + : shown(workflow.path); + }); const durableRootCandidates = (workspaceScopes ?? []).flatMap((scope) => scope.projectId ? [{ projectId: scope.projectId, cwd: scope.cwd }] : [], ); @@ -723,10 +738,16 @@ export function WorkflowsRail({ agentPaths: visibleWorkflows.map((workflow) => workflow.path), sort, }).filter(shown); - const projects = buildProjectTree(visibleWorkflows, roots, sort); + const projects = buildProjectTree( + visibleWorkflows, roots, sort, workspaceScopes, + ); // Agents no open root contains. Rarer than the old "No workspace" bucket, // but dropping them would hide an agent that exists. - const strays = unrootedAgents(visibleWorkflows, roots, sort); + const strays = unrootedAgents(visibleWorkflows, roots, sort, workspaceScopes); + const agentsInProject = (root: string): WorkflowInfo[] => + visibleWorkflows.filter((workflow) => + agentBelongsToProjectRoot(workflow, root, workspaceScopes), + ); // Every REGISTERED agent path, hidden ones included: "Open as project" counts // what a folder would bring in, and a folder that would un-hide a removed // project's agents is exactly the case that number has to be honest about. @@ -824,7 +845,7 @@ export function WorkflowsRail({ * with nowhere to be stored. */ const onGroupDrop = (root: string, request: GroupDropRequest): void => { - const rootAgents = railGroups.agentsIn(root); + const rootAgents = agentsInProject(root); if (!rootAgents.some((workflow) => workflow.path === request.path)) return; setFreshGroupLabel(null); railGroups.edit(root, rootAgents, (state) => @@ -1295,7 +1316,7 @@ export function WorkflowsRail({ // where a scope header belongs — and the root agent is often the // head of the very group being shown. const groupAgents = - axis === "group" ? railGroups.agentsIn(project.root) : []; + axis === "group" ? agentsInProject(project.root) : []; const showGroups = axis === "group" && groupAgents.length > 1; const soloAgents = groupAgents.filter( (workflow) => workflow.path !== project.rootAgent?.workflow.path, diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 1ba37ec5d..c6c6c26b8 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -3,6 +3,7 @@ import type { StudioProjectSummary } from "@shared/agent-map"; import { mostSpecificStudioScope, + studioScopeForAgent, parseAcceptedProposalDelta, parseAgentMapWorkspaceResponse, resolveStudioWorkspaceSelection, @@ -240,6 +241,33 @@ describe("resolveStudioWorkspaceSelection", () => { }); describe("mostSpecificStudioScope", () => { + it("resolves an explicitly bound sibling through its project without broadening the root", () => { + const scopes = [ + { workspaceKey: "original", cwd: "/projects/original", projectId }, + ]; + const workflow = { + path: "/projects/reviewer", + studioBindings: [{ projectId, agentId: "agent-a" }], + }; + expect( + studioScopeForAgent(workflow, scopes, [validResponseProject(projectId)]), + ).toEqual(scopes[0]); + expect(studioScopeForAgent(workflow, scopes, [])).toBeNull(); + expect( + studioScopeForAgent( + workflow, + scopes, + [validResponseProject(projectId)], + "foreign-project", + ), + ).toBeNull(); + expect( + studioScopeForAgent({ path: workflow.path }, scopes, [ + validResponseProject(projectId), + ]), + ).toBeNull(); + }); + it("chooses the nearest containing durable project, not the first parent", () => { const nestedProjectId = "project_00000000-0000-4000-8000-000000000002"; expect( diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index 807e2cb3a..fbd82a262 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -12,6 +12,7 @@ import { parseMapChangeProposal, } from "@shared/agent-map-codec"; import type { WorkspaceScopeSummary } from "@shared/system-graph"; +import type { WorkflowInfo } from "@shared/types"; import { resolveProjectRootForPath } from "../../../src/shared/project-roots.js"; import { samePath } from "./paths"; @@ -347,3 +348,44 @@ export function mostSpecificStudioScope( left.workspaceKey.localeCompare(right.workspaceKey), )[0]!; } + +/** An agent's server-issued membership can point outside its project's root. */ +export function studioScopeForAgent( + workflow: Pick, + scopes: readonly WorkspaceScopeSummary[], + projects: readonly StudioProjectSummary[], + preferredProjectId?: string | null, +): (WorkspaceScopeSummary & { projectId: string }) | null { + const bindings = workflow.studioBindings ?? []; + if (bindings.length === 0) + return preferredProjectId + ? null + : mostSpecificStudioScope(workflow.path, scopes, projects); + const eligible = projects.filter( + (project) => + (!preferredProjectId || project.projectId === preferredProjectId) && + bindings.some((binding) => binding.projectId === project.projectId), + ); + const eligibleIds = new Set(eligible.map((project) => project.projectId)); + const candidates = scopes.filter( + (scope): scope is WorkspaceScopeSummary & { projectId: string } => + Boolean(scope.projectId && eligibleIds.has(scope.projectId)), + ); + const containing = mostSpecificStudioScope( + workflow.path, + candidates, + eligible, + ); + if (containing) return containing; + // No path evidence: an explicit single project is enough, but ambiguous + // membership must not silently select a different conversation. + if (new Set(candidates.map((scope) => scope.projectId)).size !== 1) + return null; + return ( + candidates.sort( + (a, b) => + a.cwd.localeCompare(b.cwd) || + a.workspaceKey.localeCompare(b.workspaceKey), + )[0] ?? null + ); +} diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 23505bb7d..9694027c0 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -5,6 +5,7 @@ * touches the network — this is what lets the SPA build ahead of a running * server. */ +import { buildIdeaWithAttachments } from "@shared/initial-prompt"; import type { AccountPlanView, AgentSecret, @@ -2017,10 +2018,21 @@ export class MockApi implements HarnessApi { typeof window !== "undefined" && new URLSearchParams(window.location.search).get("mockNoLiveSessions") === "1"; + // A Studio restart retains registry history while every native runtime has + // exited. Keep both providers' exact saved IDs for restoration journeys. + private readonly restoredSessions = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("mockRestoredSessions") === + "1"; private sessionsStore: HarnessSession[] = this.fresh || this.noLiveSessions ? [] - : MOCK_SESSIONS.map((session) => ({ ...session })); + : MOCK_SESSIONS.map((session) => ({ + ...session, + ...(this.restoredSessions + ? { status: "exited" as const, ready: false } + : {}), + })); private workflowsStore: WorkflowInfo[] = this.fresh ? [] : [ @@ -2261,7 +2273,8 @@ export class MockApi implements HarnessApi { private studioWorkflows(): WorkflowInfo[] { const scopes = this.workspaceScopes(); - return this.workflows.map((workflow, index) => { + const workflows = this.workflows.map((workflow, index) => { + if (workflow.studioBindings?.length) return workflow; const bindings = scopes .filter( (candidate) => @@ -2278,6 +2291,28 @@ export class MockApi implements HarnessApi { } : workflow; }); + // Regression fixture: successful scaffold in the original conversation, + // but on disk beside its root. Never turn this path into a root candidate. + if ( + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("mockCreatedSibling") === "1" + ) { + const projectId = scopes.find( + (scope) => scope.cwd === "/Users/demo/acme-app", + )?.projectId; + if (projectId) workflows.push({ + name: "report-reviewer", + path: "/Users/demo/report-reviewer", + definitionId: null, + definitionSlug: "report-reviewer", + source: "scan", + studioBindings: [{ + projectId, + agentId: "agent_00000000-0000-4000-8000-000000000999", + }], + }); + } + return workflows; } async getState(): Promise { @@ -2737,14 +2772,28 @@ export class MockApi implements HarnessApi { lastCreateSession: { req }, createSessionCalls: [...previous, { req }], }; - recordCreateStep("session", req.cwd); if (win.__MOCK_CREATE_SESSION_FAIL_ONCE__) { win.__MOCK_CREATE_SESSION_FAIL_ONCE__ = false; throw new Error("mock: couldn't create session"); } } + if (req.scaffold) { + const separator = req.cwd.lastIndexOf("/"); + await this.scaffoldAgent(req.cwd.slice(0, separator), req.cwd.slice(separator + 1), req.scaffold.template); + } + const id = `sess-mock-${this.sessions.length + 1}`; + const attachments: { path: string }[] = []; + for (const attachment of req.initialAttachments ?? []) { + attachments.push(attachment.kind === "path" ? attachment : await this.materializeMockFile(id, req.cwd, attachment)); + } + const initialPrompt = buildIdeaWithAttachments(req.initialPrompt ?? "", attachments); + recordCreateStep("session", req.cwd); + if (typeof window !== "undefined" && initialPrompt) { + const win = window as unknown as { __HARNESS_TEST__?: Record }; + win.__HARNESS_TEST__ = { ...(win.__HARNESS_TEST__ ?? {}), lastInitialInput: { id, text: initialPrompt } }; + } let session: HarnessSession = { - id: `sess-mock-${this.sessions.length + 1}`, + id, agentSessionId: null, boundWorkflowPath: null, harness: req.harness, @@ -2819,6 +2868,11 @@ export class MockApi implements HarnessApi { if (!session) throw new ApiError(404, "session not found", "session not found"); + return this.materializeMockFile(id, session.cwd, req); + } + + private async materializeMockFile(id: string, cwd: string, req: AttachFileRequest): Promise { + await delay(); const testWindow = typeof window === "undefined" ? undefined @@ -2830,7 +2884,7 @@ export class MockApi implements HarnessApi { throw new ApiError( 500, "attachment materialization failed", - "attachment materialization failed", + `Couldn't attach ${req.filename}: attachment materialization failed`, ); } @@ -2839,7 +2893,7 @@ export class MockApi implements HarnessApi { throw new ApiError(400, "invalid attachment", "invalid attachment"); const filename = req.filename.split(/[\\/]/).pop() || "pasted-file"; const response: AttachFileResponse = { - path: `${session.cwd}/.sapiom/uploads/mock-${filename}`, + path: `${cwd}/.sapiom/uploads/mock-${filename}`, mediaType: match[1]!, bytes: atob(match[2]!).length, }; @@ -2879,6 +2933,17 @@ export class MockApi implements HarnessApi { } async resumeSession(id: string): Promise { + if (typeof window !== "undefined") { + const win = window as unknown as { + __HARNESS_TEST__?: Record; + }; + const previous = + (win.__HARNESS_TEST__?.resumeSessionCalls as string[] | undefined) ?? []; + win.__HARNESS_TEST__ = { + ...(win.__HARNESS_TEST__ ?? {}), + resumeSessionCalls: [...previous, id], + }; + } await delay(300); const existing = this.sessions.find( (session) => session.agentSessionId === id || session.id === id, diff --git a/packages/harness/web/src/lib/new-session-attachments.ts b/packages/harness/web/src/lib/new-session-attachments.ts index 638c45621..dee0dc12c 100644 --- a/packages/harness/web/src/lib/new-session-attachments.ts +++ b/packages/harness/web/src/lib/new-session-attachments.ts @@ -10,7 +10,6 @@ import { MAX_INLINE_ATTACHMENTS_TOTAL_BYTES, } from "@shared/types"; -import { quotePathForTerminal } from "./terminal-drop"; export type NewSessionAttachment = | { @@ -192,21 +191,4 @@ export async function materializeAttachments( return resolved; } -/** - * Preserve the user's words verbatim and add one terminal-safe path per file. - * The caller deliberately derives the project slug before calling this, so a - * filename can never rename the project being created. - */ -export function buildIdeaWithAttachments( - idea: string, - attachments: readonly ResolvedNewSessionAttachment[], -): string | undefined { - const trimmedIdea = idea.trim(); - if (attachments.length === 0) return trimmedIdea || undefined; - - const paths = attachments - .map((attachment) => quotePathForTerminal(attachment.path)) - .join("\n"); - const context = `Attached files (read each as context):\n${paths}`; - return trimmedIdea ? `${trimmedIdea}\n\n${context}` : context; -} +export { buildIdeaWithAttachments } from "@shared/initial-prompt"; diff --git a/packages/harness/web/src/lib/project-tree.test.ts b/packages/harness/web/src/lib/project-tree.test.ts index 754e1b44e..87afb48ae 100644 --- a/packages/harness/web/src/lib/project-tree.test.ts +++ b/packages/harness/web/src/lib/project-tree.test.ts @@ -40,6 +40,35 @@ const agent = ( const ROOT = "/Users/dev/polsia"; describe("buildProjectTree", () => { + it("files a creator-bound sibling under its project, not outside or in a foreign project", () => { + const root = "/projects/build-local-support"; + const reviewer = { + ...agent("/projects/report-reviewer"), + studioBindings: [{ projectId: "project-a", agentId: "agent-a" }], + }; + const workflows = [agent(root), reviewer, agent("/projects/unrelated")]; + const scopes = [ + { cwd: root, projectId: "project-a" }, + { cwd: "/projects", projectId: "project-b" }, + ]; + const [project, foreign] = buildProjectTree( + workflows, + [root, "/projects"], + "name", + scopes, + ); + expect(project.rootAgent?.workflow.path).toBe(root); + expect(project.agents.map((row) => row.workflow.path)).toEqual([ + reviewer.path, + ]); + expect(JSON.stringify(foreign)).not.toContain("report-reviewer"); + expect( + unrootedAgents(workflows, [root], "name", scopes).map( + (row) => row.workflow.path, + ), + ).toEqual(["/projects/unrelated"]); + }); + it("compacts unbranched runs and keeps the branch point", () => { const workflows = [ agent(`${ROOT}/backend/src/agents/ads`), diff --git a/packages/harness/web/src/lib/project-tree.ts b/packages/harness/web/src/lib/project-tree.ts index 3172ecdfe..872a098f5 100644 --- a/packages/harness/web/src/lib/project-tree.ts +++ b/packages/harness/web/src/lib/project-tree.ts @@ -1,6 +1,25 @@ import type { WorkflowInfo } from "@shared/types"; import type { RailSort } from "../../../src/shared/project-roots.js"; +type ProjectScope = { cwd: string; projectId?: string }; + +/** Same membership rule for the Project axis, Group axis, and outside bucket. */ +export function agentBelongsToProjectRoot( + workflow: WorkflowInfo, + root: string, + scopes: readonly ProjectScope[] = [], +): boolean { + const projectId = scopes.find((scope) => + samePath(scope.cwd, root), + )?.projectId; + if (projectId && workflow.studioBindings?.length) { + return workflow.studioBindings.some( + (binding) => binding.projectId === projectId, + ); + } + return isWithinDir(root, workflow.path); +} + import { displayAgentName } from "./agent-name"; import { basenameOf, @@ -378,13 +397,19 @@ export function buildProjectTree( workflows: WorkflowInfo[], roots: readonly string[], sort: RailSort = "recent", + scopes: readonly ProjectScope[] = [], ): ProjectNode[] { const label = projectLabeller(roots); return roots.map((root) => { const trieRoot = newTrieNode(basenameOf(root), root); + const associated: WorkflowInfo[] = []; for (const workflow of workflows) { - if (!isUnder(workflow.path, root)) continue; + if (!agentBelongsToProjectRoot(workflow, root, scopes)) continue; + if (!isUnder(workflow.path, root)) { + associated.push(workflow); + continue; + } const segments = segmentsBetween(root, workflow.path); if (segments.length === 0) { // The root folder is itself an agent project. @@ -410,7 +435,9 @@ export function buildProjectTree( root, label: label(root), dirs, - agents, + agents: [...agents, ...unrootedAgents(associated, [], sort)].sort( + agentOrder(sort), + ), rootAgent: trieRoot.agent ? { workflow: trieRoot.agent, prefix: "", prefixFull: "" } : null, @@ -455,7 +482,10 @@ export function agentPrefixes( root: string, ): Map { const chains = workflows.map((workflow) => - segmentsBetween(root, workflow.path).slice(0, -1), + (isUnder(workflow.path, root) + ? segmentsBetween(root, workflow.path) + : canonical(workflow.path).split("/").filter(Boolean) + ).slice(0, -1), ); // COMPARE WHAT THE ROW PRINTS, not the registry's raw name. The row renders // `displayAgentName`, which strips an npm scope and a leading `example-` — so @@ -492,9 +522,11 @@ export function unrootedAgents( workflows: WorkflowInfo[], roots: readonly string[], sort: RailSort = "recent", + scopes: readonly ProjectScope[] = [], ): AgentNode[] { const outside = workflows.filter( - (workflow) => !roots.some((root) => isUnder(workflow.path, root)), + (workflow) => + !roots.some((root) => agentBelongsToProjectRoot(workflow, root, scopes)), ); // The chain ABOVE each agent's own directory, canonical, deepest segment // last. There is no project root to measure from here, so the chain is the diff --git a/packages/harness/web/src/lib/session-scope.test.ts b/packages/harness/web/src/lib/session-scope.test.ts index 744bf590f..101ca4a13 100644 --- a/packages/harness/web/src/lib/session-scope.test.ts +++ b/packages/harness/web/src/lib/session-scope.test.ts @@ -707,6 +707,108 @@ describe("sessionForFocus: selection moves the session across projects, never wi ).toEqual({ kind: "switch", to: nestedSession }); }); + it("keeps the creating project's chat for an off-root agent, without absorbing foreign sessions", () => { + const original = at({ + id: "original", + cwd: "/projects/original", + agentMapIdentity: { projectId: "project_original" }, + }); + const foreign = at({ + id: "foreign", + cwd: "/projects", + agentMapIdentity: { projectId: "project_foreign" }, + }); + const scope = { + focusPath: "/projects/reviewer", + roots: ["/projects/original", "/projects"], + targetProjectId: "project_original", + targetProjectRoot: "/projects/original", + }; + expect( + sessionForFocus({ + ...scope, + active: original, + sessions: [original, foreign], + }), + ).toEqual({ kind: "keep" }); + expect( + sessionReachesFocus( + original, + scope.focusPath, + scope.roots, + scope.targetProjectId, + scope.targetProjectRoot, + ), + ).toBe(true); + expect( + sessionForFocus({ + ...scope, + active: foreign, + sessions: [original, foreign], + }), + ).toEqual({ kind: "switch", to: original }); + expect( + sessionForFocus({ ...scope, active: foreign, sessions: [foreign] }), + ).toEqual({ kind: "switch", to: null }); + }); + + it("keeps an explicitly selected exited conversation for its creating project's sibling", () => { + const archived = at({ + id: "archived", + cwd: "/projects/original", + status: "exited", + agentMapIdentity: { projectId: "project_original" }, + }); + const live = at({ ...archived, id: "live", status: "running" }); + const scope = { + focusPath: "/projects/reviewer", + roots: ["/projects/original"], + targetProjectId: "project_original", + targetProjectRoot: "/projects/original", + }; + for (const sessions of [[archived], [archived, live]]) { + expect( + sessionForFocus({ ...scope, active: archived, sessions }), + ).toEqual({ kind: "keep" }); + } + // Retaining a recorded conversation does not grant it a live runtime. + expect( + sessionReachesFocus( + archived, + scope.focusPath, + scope.roots, + scope.targetProjectId, + scope.targetProjectRoot, + ), + ).toBe(false); + }); + + it("never picks an exited conversation implicitly or retains one from a different project", () => { + const archived = at({ + id: "archived", + cwd: "/projects/original", + status: "exited", + agentMapIdentity: { projectId: "project_original" }, + }); + const scope = { + focusPath: "/projects/reviewer", + roots: ["/projects/original"], + targetProjectId: "project_original", + targetProjectRoot: "/projects/original", + sessions: [archived], + }; + for (const active of [ + null, + at({ ...archived, agentMapIdentity: { projectId: "project_foreign" } }), + at({ ...archived, agentMapIdentity: null }), + ]) { + expect(sessionForFocus({ ...scope, active })).toEqual({ + kind: "switch", + to: null, + }); + } + }); + it("falls back safely for identity-less legacy sessions without admitting an outer root", () => { const nested = `${POLSIA}/services/workers`; const worker = `${nested}/ads`; diff --git a/packages/harness/web/src/lib/session-scope.ts b/packages/harness/web/src/lib/session-scope.ts index 1282ca056..144195450 100644 --- a/packages/harness/web/src/lib/session-scope.ts +++ b/packages/harness/web/src/lib/session-scope.ts @@ -337,7 +337,8 @@ export interface FocusSessionInput { } export type FocusSessionDecision = - /** The active session already reaches this agent. Do not touch it. */ + /** Keep the current live session or an explicitly selected same-project + * archived conversation. This decision does not resume a runtime. */ | { kind: "keep" } /** Hand over to another project's session, or to none (the honest * "start a session" state). */ @@ -379,6 +380,17 @@ export function sessionForFocus({ targetProjectId = null, targetProjectRoot = null, }: FocusSessionInput): FocusSessionDecision { + // History selection owns an exact recorded conversation after restart. + // Selecting one of that project's agents must not discard it or replace it + // with a live tab. Require durable identity; an exited session's cwd alone + // cannot establish the creating project of a scaffolded sibling. + if ( + active?.status === "exited" && + targetProjectId && + active.agentMapIdentity?.projectId === targetProjectId + ) + return { kind: "keep" }; + const live = sessions.filter((session) => session.status !== "exited"); if ( diff --git a/packages/harness/web/src/lib/terminal-drop.ts b/packages/harness/web/src/lib/terminal-drop.ts index 5c3a0f13b..30eb04c10 100644 --- a/packages/harness/web/src/lib/terminal-drop.ts +++ b/packages/harness/web/src/lib/terminal-drop.ts @@ -12,31 +12,8 @@ * halves live in Terminal.tsx. */ -/** - * Characters that survive unquoted in every consumer we care about. Includes - * `\` and `:` so Windows paths (`C:\Users\…`) don't get quoted needlessly. - */ -const SAFE_PATH = /^[A-Za-z0-9_\-./~:\\]+$/; - -/** A Windows absolute path (`C:\…` or `C:/…`). */ -const WINDOWS_PATH = /^[A-Za-z]:[\\/]/; - -/** - * Quote a path the way a native terminal's drop does: leave safe paths bare, - * double-quote anything with spaces or shell-special characters. Double quotes - * rather than backslash-escaping because they read the same on POSIX and - * Windows, and the agent CLIs accept both forms. - * - * Escaping inside the quotes is per-flavor: a Windows path is quoted verbatim - * (`"` is not a legal filename character there, and escaping would corrupt the - * backslash separators), while a POSIX path gets `\` and `"` backslash-escaped - * so neither can terminate the quoting early. - */ -export function quotePathForTerminal(path: string): string { - if (SAFE_PATH.test(path)) return path; - if (WINDOWS_PATH.test(path)) return `"${path}"`; - return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} +import { quotePathForTerminal } from "@shared/initial-prompt"; +export { quotePathForTerminal }; /** * The text a drop types into the pty: each resolved path quoted, space diff --git a/packages/harness/web/src/lib/ui-prefs.ts b/packages/harness/web/src/lib/ui-prefs.ts index 3c495b39e..7a4928f99 100644 --- a/packages/harness/web/src/lib/ui-prefs.ts +++ b/packages/harness/web/src/lib/ui-prefs.ts @@ -11,6 +11,9 @@ import type { RailAxis, RailSort } from "./project-tree"; */ export interface UiPrefs { + /** Explicit conversation selection, including an archived session. Restored + * only when the server still lists this ID; never resumes its runtime. */ + activeSessionId?: string | null; railCollapsed?: boolean; rightCollapsed?: boolean; /** diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 65c51c426..e176734cf 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -707,6 +707,9 @@ export function useHarnessState(): HarnessStateHook { const switchSeqRef = useRef(0); const selectSession = useCallback((id: string | null): void => { switchSeqRef.current += 1; + // Persist at explicit selection, not from an effect: initial hydration's + // null must never overwrite the conversation being restored. + saveUiPrefs({ activeSessionId: id }); setActiveSessionId(id); }, []); @@ -1148,10 +1151,13 @@ export function useHarnessState(): HarnessStateHook { setSettings(harnessSettings); setErrorKind(null); if (appState.tasks) setTasks(appState.tasks); - const running = appState.sessions.find( - (session) => session.status !== "exited", - ); - if (running) setActiveSessionId(running.id); + const savedSessionId = loadUiPrefs().activeSessionId; + const selected = + appState.sessions.find((session) => session.id === savedSessionId) ?? + appState.sessions.find((session) => session.status !== "exited"); + // This is display hydration only. An exited record stays exited and + // a stale preference keeps the existing live-session fallback. + if (selected) setActiveSessionId(selected.id); if (workflows[0]) setSelectedWorkflowPath(workflows[0].path); }) .catch((err: unknown) => { @@ -1941,6 +1947,10 @@ export function useHarnessState(): HarnessStateHook { const ending = new Set(plan.endSessionIds); if (ending.size > 0) { switchSeqRef.current += 1; + const savedSessionId = loadUiPrefs().activeSessionId; + if (savedSessionId && ending.has(savedSessionId)) { + saveUiPrefs({ activeSessionId: null }); + } setActiveSessionId((prev) => prev != null && ending.has(prev) ? null : prev, ); diff --git a/packages/harness/web/src/styles.css b/packages/harness/web/src/styles.css index 8ea6697bd..7ff5666fa 100644 --- a/packages/harness/web/src/styles.css +++ b/packages/harness/web/src/styles.css @@ -4701,6 +4701,13 @@ button.system-graph-node.is-navigable:focus-visible { transition: none; } +/* A release over the canvas can be routed into its child frame despite + pointer capture. Keep the whole resize gesture in the parent document so + releasing the handle ends the drag and persists its height. */ +.canvas-frame-wrap:has(.canvas-overview.is-resizing) .canvas-iframe { + pointer-events: none; +} + @media (prefers-reduced-motion: reduce) { .canvas-overview { transition: none; diff --git a/packages/harness/web/tsconfig.json b/packages/harness/web/tsconfig.json index 61a47fea1..d1d2bf9de 100644 --- a/packages/harness/web/tsconfig.json +++ b/packages/harness/web/tsconfig.json @@ -13,6 +13,7 @@ "useDefineForClassFields": true, "types": ["vite/client", "node"], "paths": { + "@shared/initial-prompt": ["../src/shared/initial-prompt.ts"], "@shared/types": ["../src/shared/types.ts"], "@shared/system-graph": ["../src/shared/system-graph.ts"], "@shared/agent-map": ["../src/shared/agent-map.ts"], diff --git a/packages/harness/web/vite.config.ts b/packages/harness/web/vite.config.ts index 39ada7a30..b7be84218 100644 --- a/packages/harness/web/vite.config.ts +++ b/packages/harness/web/vite.config.ts @@ -86,6 +86,9 @@ export default defineConfig({ }, resolve: { alias: { + "@shared/initial-prompt": fileURLToPath( + new URL("../src/shared/initial-prompt.ts", import.meta.url), + ), // The frontend imports its runtime contract from `@shared/types`. It // resolves to the package's own canonical shared contract // (packages/harness/src/shared/types.ts) so the web and server always diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 8cec6065f..5d2b6f3cc 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -192,8 +192,8 @@ "id": "server-workflow-change-event", "path": "packages/harness/src/server/index.ts", "pattern": "^workflows\\.changed$", - "occurrences": 1, - "reason": "The internal WebSocket event name remains stable for existing clients; accepted agent discovery publishes it from the coordinator." + "occurrences": 2, + "reason": "The internal WebSocket event name remains stable for existing clients; accepted discovery and created-agent registration publish it." }, { "id": "rest-session-binding-route", @@ -577,6 +577,14 @@ "occurrences": 1, "reason": "Migration fixture proves a durable pre-upgrade input FIFO is normalized without losing input or replaying bootstrap." }, + { + "id": "legacy-project-bootstrap-store-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/project-bootstrap-store.test.ts", + "pattern": "map-planner", + "occurrences": 1, + "reason": "Migration fixture proves the stored bootstrap queue normalizes its pre-upgrade principal without losing accepted input." + }, { "id": "legacy-session-migration-fixtures", "rule": "unified-agent-model", @@ -600,13 +608,5 @@ "pattern": "plannerOrigin", "occurrences": 1, "reason": "Released-event fixture proves the private infrastructure bootstrap prompt never becomes a human transcript turn after upgrade." - }, - { - "id": "legacy-project-bootstrap-store-fixture", - "rule": "unified-agent-model", - "path": "packages/harness/src/core/project-bootstrap-store.test.ts", - "pattern": "map-planner", - "occurrences": 1, - "reason": "Extracted persistence fixture verifies the released bootstrap queue migrates without replacing its project identity." } ]