From f6c6027f9c737f3e9b8a27d8bc717ce871fe18c0 Mon Sep 17 00:00:00 2001 From: bo Date: Fri, 31 Jul 2026 01:33:07 +0800 Subject: [PATCH 1/4] feat(todos): add discussion planning and goal handoff Introduce the dedicated Discussion agent and Plan workflow skills. Route Todo planning and Start Work handoff through durable Sessions while keeping Goal optional, and cover the behavior across runtime, server, Web, architecture tests, and documentation. --- AGENTS.md | 30 +- README.md | 8 +- .../src/routes/attachment-http-error.ts | 2 +- apps/server/src/routes/messages.test.ts | 66 ++++ apps/server/src/routes/messages.ts | 70 +++- apps/server/src/routes/sessions.test.ts | 6 +- apps/server/src/routes/todos.test.ts | 26 ++ apps/server/src/routes/todos.ts | 9 + .../components/composite/ToolCard.test.tsx | 2 +- .../web/src/components/composite/ToolCard.tsx | 2 +- apps/web/src/lib/agent-constants.test.ts | 5 +- apps/web/src/lib/agent-constants.ts | 1 + .../src/routes/project-todos.interaction.tsx | 338 ++++++++++++++-- apps/web/src/routes/project-todos.test.tsx | 265 ++++++++++++- apps/web/src/routes/project-todos.tsx | 260 +++++++++++- .../src/routes/root-layout.interaction.tsx | 42 +- apps/web/src/routes/root-layout.tsx | 2 +- design-system/MASTER.md | 2 + design-system/pages/todos.md | 38 +- design-system/prototypes/todos.html | 53 ++- docs/agents/multi-agent-design.md | 31 +- docs/concepts.md | 37 +- docs/configuration.md | 6 +- ...ssion-plan-execution-hard-cut-plan-goal.md | 255 ++++++++++++ ...ussion-plan-execution-hard-cut-progress.md | 142 +++++++ .../src/__arch__/goal-boundaries.test.ts | 4 +- .../tool-output-policy-matrix.test.ts | 4 +- .../src/agents/configured-agent.test.ts | 60 ++- .../agent-core/src/agents/configured-agent.ts | 62 +-- .../agents/definitions/definitions.test.ts | 55 ++- .../src/agents/definitions/discussion.ts | 92 +++++ .../src/agents/definitions/index.ts | 3 + .../agent-core/src/agents/definitions/lead.ts | 35 +- .../src/agents/definitions/role-contracts.ts | 22 ++ packages/agent-core/src/agents/errors.ts | 2 + .../agent-core/src/agents/factory.test.ts | 29 +- packages/agent-core/src/agents/index.ts | 9 + packages/agent-core/src/agents/names.ts | 1 + .../src/agents/root-session-identity.ts | 55 +++ .../agent-core/src/agents/session-profile.ts | 5 +- .../agent-core/src/commands/registry.test.ts | 4 + packages/agent-core/src/commands/registry.ts | 2 +- .../src/delegation/contract.test.ts | 4 +- .../session-execution-manager.test.ts | 107 ++--- .../execution/session-execution-manager.ts | 30 +- ...ead-architecture-flows.integration.test.ts | 370 +++++++++++++++++- packages/agent-core/src/main.test.ts | 13 +- .../agent-core/src/prompt/compiler.test.ts | 13 +- packages/agent-core/src/prompt/lint.ts | 13 +- .../src/runtime-automations.test.ts | 16 +- packages/agent-core/src/runtime.ts | 16 +- .../src/session-goal/service.test.ts | 30 +- .../session-input/model-selection-service.ts | 29 +- .../src/session-input/model-selection.test.ts | 15 +- .../src/skills/builtin/execute-plan/SKILL.md | 14 + .../agent-core/src/skills/builtin/manifest.ts | 2 + .../src/skills/builtin/plan-work/SKILL.md | 24 +- .../src/skills/builtin/shape-todo/SKILL.md | 7 +- .../agent-core/src/skills/service.test.ts | 4 +- packages/agent-core/src/skills/service.ts | 1 + packages/agent-core/src/store/helpers.test.ts | 33 +- packages/agent-core/src/store/helpers.ts | 9 +- .../src/store/session-store-manager.test.ts | 12 +- .../src/store/session-store-manager.ts | 15 +- packages/agent-core/src/store/types.ts | 2 +- packages/agent-core/src/todos/schema.ts | 9 + packages/agent-core/src/todos/service.test.ts | 66 +++- packages/agent-core/src/todos/service.ts | 56 ++- .../tools/builtins/automation-create.test.ts | 5 +- .../src/tools/builtins/automation-create.ts | 2 +- .../builtins/model-visible-contract.test.ts | 4 +- .../builtins/project-todo-update.test.ts | 12 +- .../src/tools/builtins/project-todo-update.ts | 6 +- .../src/tools/builtins/session-goal.test.ts | 8 +- .../src/tools/builtins/session-goal.ts | 3 - .../agent-core/src/tools/builtins/worktree.ts | 1 - packages/protocol/src/model-runtime.ts | 2 +- packages/protocol/src/project-todos.ts | 4 +- packages/protocol/src/types.ts | 8 +- 79 files changed, 2668 insertions(+), 439 deletions(-) create mode 100644 docs/goals/todo-discussion-plan-execution-hard-cut-plan-goal.md create mode 100644 docs/goals/todo-discussion-plan-execution-hard-cut-progress.md create mode 100644 packages/agent-core/src/agents/definitions/discussion.ts create mode 100644 packages/agent-core/src/agents/root-session-identity.ts create mode 100644 packages/agent-core/src/skills/builtin/execute-plan/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 2f40d4b8..8ab3f070 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ ## Project -ArchCode is an open-source, self-hosted AI coding workbench. Users run it on a local machine or remote server, capture features, bugs, refactors, experiments, and other ideas as Project Todos, shape Ideas through dedicated Discussions, and start Ready or In Progress work as durable Sessions or Automations. A Goal is an optional persistent protocol on a root Lead Session, not a separate work item. ArchCode runs as a Hono server + React Web UI rather than a one-off CLI, with five Agent identities (Lead, Analyst, Build, Explore, Librarian), three model Profiles (`principal`, `deep`, `fast`), workflow Skills, HITL/Automation primitives, structured tool execution, LSP integration, persistent memory, and context compaction. +ArchCode is an open-source, self-hosted AI coding workbench. Users run it on a local machine or remote server, capture features, bugs, refactors, experiments, and other ideas as Project Todos, shape Ideas through dedicated Discussions, and start Ready or In Progress work as durable Sessions or Automations. A Goal is an optional persistent protocol on a root Lead Session, not a separate work item. ArchCode runs as a Hono server + React Web UI rather than a one-off CLI, with six Agent identities (Lead, Discussion, Analyst, Build, Explore, Librarian), three model Profiles (`principal`, `deep`, `fast`), workflow Skills, HITL/Automation primitives, structured tool execution, LSP integration, persistent memory, and context compaction. ## Monorepo Structure @@ -256,7 +256,7 @@ Delegation: `delegate(DelegationRequest)` creates a durable direct child; `resum **Multi-project model:** - `packages/agent-core/src/projects/registry.ts` persists registered workspaces under `~/.archcode/projects/index.json`, validates absolute existing directories, derives stable slugs, and tracks open times. - `packages/agent-core/src/projects/context-resolver.ts` creates per-workspace runtime context: durable HITL, project memory, approvals, and resource notifications. Automation state is owned by the Automation service. -- Ordinary Session routes create a `lead`. A root Lead Session may own one optional `Session.goal`; no Goal-specific Session, route family, or worktree exists. A Todo-originated root Lead stores its immutable `{ todoId, entry }` source on the Session itself. A `discussion` entry is a restricted root Lead derived from that Session identity. +- Ordinary Session routes create a `lead`. A root Lead Session may own one optional `Session.goal`; no Goal-specific Session, route family, or worktree exists. A Todo-originated root stores its immutable `{ todoId, entry }` source on the Session itself: `discussion` requires a root `discussion` Agent, while `work` and `automation` require a root `lead`. - Web UI Add Project flow should register an existing workspace directory, then use project-scoped API routes (`/api/projects/:slug/...`) for sessions, files, automations, HITL, and events. **Project `.archcode` layout** (per registered workspace root; user-global `~/.archcode` is unchanged): @@ -301,7 +301,7 @@ Every descriptor declares an explicit `outputPolicy`. Registry is the sole Raw-t - `provider..models..options` defines base AI SDK model-call options for that model. Use AI SDK camelCase names such as `maxOutputTokens`, `temperature`, `topP`, `topK`, `presencePenalty`, `frequencyPenalty`, `stopSequences`, `seed`, `timeout`, and `providerOptions`. - `provider..models..variants.` defines named option variants for the same model. A Profile or Session override may reference one; the variant name is consumed during resolution and never passed to the AI SDK call. - `profiles.principal`, `profiles.deep`, and `profiles.fast` are all required, and unknown configuration keys fail strict validation. -- Profile-default merge order is shallow: model `options` → selected `variants[variant]` → Profile `options`. A root Lead Session override resolves independently and never inherits principal Profile options. +- Profile-default merge order is shallow: model `options` → selected `variants[variant]` → Profile `options`. A user-facing root Session override resolves independently and never inherits principal Profile options. - `providerOptions` follows the same shallow merge rule as one top-level key: later layers replace the whole `providerOptions` object rather than deep-merging nested provider settings. - Unknown model ids, unknown variant names, and missing Profile config all fail fast with actionable errors. - LLM execution is centralized in `packages/agent-core/src/llm/`. Non-LLM runtime code must not import `streamText` or `generateText` directly from `"ai"`; use `runLlmStream`, `runLlmText`, or `runLlmObject` instead. @@ -371,26 +371,27 @@ Minimal example: | Agent | Profile | Notes | |------|---------|-------| -| **Lead** (`"lead"`) | root default `principal` | Sole user entry and final technical owner. Works directly, delegates bounded work, owns Plan files, Goal/Automation requests, integration, verification, and delivery. | +| **Lead** (`"lead"`) | root default `principal` | Ordinary user-work entry and final technical owner. Works directly, delegates bounded work, owns Plan files, Goal/Automation requests, integration, verification, and delivery. | +| **Discussion** (`"discussion"`) | root default `principal` | Shapes one bound Todo and its optional Plan without implementing product work. May delegate evidence gathering to Explore/Librarian. | | **Analyst** (`"analyst"`) | `deep` | Source-read-only architecture analysis, planning support, gap analysis, and independent review. May delegate evidence gathering to Explore/Librarian. | | **Build** (`"build"`) | delegated `deep` or `fast` | Source writer with file write/edit, Bash, LSP, Git diff/status, and `ast_grep_replace`. May delegate local research to Explore. | | **Explore** (`"explore"`) | `fast` | Terminal read-only local code search/LSP/Git/AST agent. | | **Librarian** (`"librarian"`) | `fast` | Terminal read-only documentation/reference agent with local read/search, web_fetch, memory_read, and MCP docs/search tools. | -All five implement `Agent`: `store: StoreApi`, `run(options) → AgentResult`; SessionExecutionManager commits input before invoking the Agent. Visual is documentation-only future scope and has no runtime identity. +All six implement `Agent`: `store: StoreApi`, `run(options) → AgentResult`; SessionExecutionManager commits input before invoking the Agent. Visual is documentation-only future scope and has no runtime identity. **Delegation + tool filtering:** - Tool sets are hardcoded by `AgentDefinition`; typed RoleContract and Prompt layers describe behavior but never change runtime permissions. - Profiles route model resources only; Skills provide guidance only. Neither changes tools, delegation targets, or completion authority. -- `lead` uses `childPolicy.maxDepth = 3`; `analyst` and `build` use `maxDepth = 2`. A Todo-bound restricted root Lead may delegate Explore/Librarian within depth 2. +- `lead` uses `childPolicy.maxDepth = 3`; `discussion`, `analyst`, and `build` use `maxDepth = 2`. Discussion may delegate Explore/Librarian. - Lead targets Analyst/Build/Explore/Librarian; Analyst targets Explore/Librarian; Build targets Explore. - `explore` and `librarian` have no `delegateTargets`; they are terminal read-only support agents. - `agents/factory.ts` resolves definition-based allowed tools and removes delegation capabilities at the runtime depth boundary; SessionExecutionManager enforces each role's child policy before child creation. - `delegate` persists Agent, Profile, Skills, title, objective, and background choice. `resume_session` preserves that identity. Multiple Builds share general Session concurrency; there is no owned-scope or Build lease subsystem. **Workflow Skills:** -- Ordinary root Lead activates `orchestrate-work`; active Goal activates `run-goal`; Todo Discussion activates `shape-todo`, derived from authoritative runtime facts on every Execution. -- `plan-work` writes an ordinary Markdown Plan under `.archcode/plans/`; Plan has no service, state, ID, API, UI, or Goal link. +- Ordinary root Lead activates `orchestrate-work`; active Goal activates `run-goal`; root Discussion activates `shape-todo`, derived from authoritative runtime facts on every Execution. +- `plan-work` writes one ordinary Markdown Plan per Todo under `.archcode/plans/`. Plan has no service, state, ID, API, dedicated page, or Goal link. `execute-plan` is activated only by the Todo-to-work handoff when that file exists. - `review-work` guides Lead review orchestration. Analyst analysis/review Skills include `analyze-work`, `review-change`, and the reserved `goal-review` final gate. **MCP visibility by agent:** @@ -398,6 +399,7 @@ All five implement `Agent`: `store: StoreApi`, `run(options) | Agent | MCP servers | |-------|-------------| | `lead` | `context7`, `exa` | +| `discussion` | — | | `analyst` | `context7` | | `build` | — | | `explore` | — | @@ -423,13 +425,13 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in | Search / AST | grep✅, glob✅, ast_grep_search✅, ast_grep_replace❌ | Search tools are workspace-scoped. `ast_grep_replace` is destructive and preview-first. | | Git / GitHub | git_status✅, git_diff✅, github_get_pull_request✅, github_list_pull_requests✅, github_get_pull_request_checks✅, github_list_issue_comments✅, github_create_issue_comment❌, github_list_workflow_runs✅, github_get_workflow_run✅, github_rerun_workflow_run❌ | GitHub connectors are registered globally but are not default agent tools. | | Shell | bash❌✅destructive | Permission: finite path-aware Bash analysis, deterministic deny/ask, default allow | -| Interaction | ask_user✅❌not-concurrent, todo_write❌, project_todo_update❌ | ask_user serializes (interactive); `project_todo_update` derives its Todo from the current bound restricted Lead Discussion and requires `expectedRevision` | +| Interaction | ask_user✅❌not-concurrent, todo_write❌, project_todo_update❌ | ask_user serializes (interactive); `project_todo_update` derives its Todo from the current bound root Discussion and requires `expectedRevision` | | Web | web_fetch✅ | — | | LSP | lsp_diagnostics✅, lsp_goto_definition✅, lsp_find_references✅, lsp_symbols✅ | Guard: workspace | | Delegation / Skills | delegate❌, resume_session❌, background_output✅, wait_for_reminder✅, cancel_session❌, skill_list✅, skill_read✅ | `delegate` accepts only strict `{ agent_type, profile, title, objective, skills, background }`; `resume_session` accepts only `{ session_id, instruction, background }`; delegated roles return ordinary final assistant text. Only Lead has family cancel. | | Tool output recovery | output_read✅, output_search✅ | All agents may retrieve only authorized, bounded artifact pages or search results. | | Memory | memory_read✅, memory_write❌ | memory_write rejects secrets | -| Goal / Automation creation | create_goal❌, get_goal✅, update_goal❌, automation_create❌ | Before a non-Discussion root Lead calls strict `create_goal({ objective })`, it uses ordinary `ask_user` and interprets the answer semantically. Goal creation never parses an initial budget from objective text; users control budget through the Session API/UI. Before completion, Lead uses a fresh direct deep Analyst with `goal-review`, interprets its ordinary report, and calls strict `update_goal({ status, reason })`; Runtime retains only active-family and instance/generation consistency checks. | +| Goal / Automation creation | create_goal❌, get_goal✅, update_goal❌, automation_create❌ | Before a root Lead calls strict `create_goal({ objective })`, it uses ordinary `ask_user` and interprets the answer semantically. Goal creation never parses an initial budget from objective text; users control budget through the Session API/UI. Before completion, Lead uses a fresh direct deep Analyst with `goal-review`, interprets its ordinary report, and calls strict `update_goal({ status, reason })`; Runtime retains only active-family and instance/generation consistency checks. | (✅ = readOnly, ❌ = not readOnly, ✅destructive = only destructive tool) @@ -439,7 +441,7 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in ## Session Store -Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and, when present, an immutable `projectTodo` source; an optional `goal` belongs only to a root Lead Session. `projectTodo` is valid only on a root Lead and records `{ todoId, entry }`, where entry is `discussion`, `work`, or `automation`. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. +Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and, when present, an immutable `projectTodo` source; an optional `goal` belongs only to a root Lead Session. `projectTodo` is valid only on a user-facing root and records `{ todoId, entry }`, where entry is `discussion`, `work`, or `automation`; strict identity validation requires `discussion` entry ↔ Discussion Agent and `work`/`automation` entry ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. ## Context Compaction @@ -455,9 +457,9 @@ Project: `.archcode/runtime/memory/`, User: `~/.archcode/memory/` (user-global, ## Project Todos -Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Each Project opens its `/projects/:slug/todos` board by default, while `/projects/:slug` remains the Project Dashboard. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, and the one canonical array order. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update and creates a new Root Lead Session for a Todo entry. A Todo never points back to Sessions or Automations. +Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Each Project opens its `/projects/:slug/todos` board by default, while `/projects/:slug` remains the Project Dashboard. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, and the one canonical array order. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update and creates a root Discussion Session for `discussion`, or a root Lead Session for `work` and `automation`. A Todo never points back to Sessions, Plans, or Automations. -A Todo can have any number of direct root Sessions. Each such root stores its immutable `{ todoId, entry }` source; children never copy it. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. `work` and `automation` roots may start only from Ready or In Progress; starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. Creating an Automation copies the source `todoId` into the Automation's own optional `projectTodoId`; Automation Invocation Sessions are not direct Todo relations. Todo moves never create, stop, rebind, or delete Sessions or Automations. +A Todo can have any number of direct root Sessions. Each such root stores its immutable `{ todoId, entry }` source; children never copy it. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. **Generate / Improve Plan** reuses the latest Discussion or creates one, then invokes `plan-work` for the unique `.archcode/plans/.md`; a newly created Discussion receives that Plan request as its first accepted message, never as a second command racing the generic Discussion start. No Plan existence is stored or exposed through Todo APIs. `work` and `automation` roots may start only from Ready or In Progress. At work creation only, `ProjectTodoService` checks that Plan path: an existing file starts with `execute-plan`, while no file preserves ordinary implementation behavior. Starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. Creating an Automation copies the source `todoId` into the Automation's own optional `projectTodoId`; Automation Invocation Sessions are not direct Todo relations. Todo moves never create, stop, rebind, or delete Sessions or Automations. ## HITL @@ -465,7 +467,7 @@ HITL is a durable project-scoped approval/question queue backed by `.archcode/ru ## Automation System -`packages/agent-core/src/automations/` owns schedule calculation, durable Invocation persistence, and dispatch to the ordinary Session API. After the user confirms the creation summary, an ordinary root Lead Session calls `automation_create`; the Runtime rejects Todo-bound Discussions and commits the Automation through the existing scheduler/state path. When the creating root has a Todo source, the new Automation copies its `todoId` into its own `projectTodoId`; Invocation Sessions do not inherit that source. An Automation has exactly one `once`, `interval`, or `cron + timezone` trigger and one action: create an ordinary Lead Session or send a message to an existing Session. Session execution, Agent behavior, permissions, HITL, Session Goal state, and worktree lifecycle remain outside Automation. +`packages/agent-core/src/automations/` owns schedule calculation, durable Invocation persistence, and dispatch to the ordinary Session API. After the user confirms the creation summary, a root Lead Session calls `automation_create` and commits the Automation through the existing scheduler/state path; Discussion does not expose that capability. When the creating root has a Todo source, the new Automation copies its `todoId` into its own `projectTodoId`; Invocation Sessions do not inherit that source. An Automation has exactly one `once`, `interval`, or `cron + timezone` trigger and one action: create an ordinary Lead Session or send a message to an existing Session. Session execution, Agent behavior, permissions, HITL, Session Goal state, and worktree lifecycle remain outside Automation. ## LSP Integration diff --git a/README.md b/README.md index 458929fd..e4bdd4aa 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ verified results. Click for the full-resolution MP4.* 1. **Capture an idea** — save anything you may want to build, fix, investigate, or improve as a Project Todo. 2. **Shape the work** — discuss the Todo with an Agent until the scope and - expected outcome are clear. + expected outcome are clear; generate or refine its single Markdown Plan when + the work needs one. 3. **Mark it Ready** — keep rough ideas separate from work that is ready to run. 4. **Start the work** — launch a fresh Lead Session or an Automation from the Todo. @@ -75,6 +76,7 @@ without mixing everything into one conversation. | Agent | Responsibility | Model Profile | |---|---|---| | Lead | Owns the outcome, works directly, and coordinates other Agents | `principal` | +| Discussion | Shapes one Todo and its optional Plan without implementing it | `principal` | | Analyst | Deep analysis, planning support, and review | `deep` | | Build | Implementation and verification | `deep` or `fast` | | Explore | Fast codebase investigation | `fast` | @@ -90,8 +92,8 @@ ArchCode separates Agent responsibility from model choice. Configure judgment matters and fast or local models for exploration and routine work. Connect official AI SDK providers, custom OpenAI-compatible endpoints, or -Responses-compatible endpoints. A root Lead Session can also override its model -without changing the Agent's tools or responsibility. See [provider and model +Responses-compatible endpoints. A user-facing root Session can also override +its model without changing the Agent's tools or responsibility. See [provider and model configuration](docs/configuration.md). ## Stay in control diff --git a/apps/server/src/routes/attachment-http-error.ts b/apps/server/src/routes/attachment-http-error.ts index f39f1db6..5c266fae 100644 --- a/apps/server/src/routes/attachment-http-error.ts +++ b/apps/server/src/routes/attachment-http-error.ts @@ -60,7 +60,7 @@ export function mapAttachmentHttpError( if (error instanceof NotRootSessionError) { return new ServerError( "ATTACHMENT_INVALID", - `Session "${sessionId}" is not a root Lead Session`, + `Session "${sessionId}" is not a user-facing root Session`, 400, ); } diff --git a/apps/server/src/routes/messages.test.ts b/apps/server/src/routes/messages.test.ts index e19bf4cf..06c98fdb 100644 --- a/apps/server/src/routes/messages.test.ts +++ b/apps/server/src/routes/messages.test.ts @@ -250,6 +250,72 @@ describe("messages routes", () => { expect(response.status).toBe(409); }); + test("maps code-identical command conflicts across hot-reload class identities", async () => { + const { app, project, runtime } = await createTestApp("command-conflict-reload"); + (runtime.acceptSessionMessage as unknown as ReturnType).mockImplementation(async () => { + throw Object.assign(new Error("commands require an idle root Session"), { + code: "SESSION_COMMAND_CONFLICT", + sessionId: "session-1", + }); + }); + const response = await app.request(`/api/projects/${project.slug}/sessions/session-1/messages`, { + method: "POST", + body: JSON.stringify({ + text: "/skill use plan-work", + attachmentIds: [], + clientRequestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + requestedModelSelection, + }), + headers: { "content-type": "application/json" }, + }); + + expect(response.status).toBe(409); + expect((await response.json()).error.details).toEqual({ + scopeCode: "SESSION_COMMAND_CONFLICT", + sessionId: "session-1", + }); + }); + + test("maps an active HITL tool batch to a stable conflict", async () => { + const { app, project, runtime } = await createTestApp("active-hitl"); + const activeHitlError = Object.assign( + new Error( + "Session \"session-1\" is waiting for a human-in-the-loop response: hitl-1.", + ), + { + code: "SESSION_TOOL_BATCH_ACTIVE", + sessionId: "session-1", + hitlIds: ["hitl-1"], + }, + ); + (runtime.acceptSessionMessage as unknown as ReturnType).mockImplementation(async () => { + throw activeHitlError; + }); + const response = await app.request(`/api/projects/${project.slug}/sessions/session-1/messages`, { + method: "POST", + body: JSON.stringify({ + text: "/skill use plan-work", + attachmentIds: [], + clientRequestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + requestedModelSelection, + }), + headers: { "content-type": "application/json" }, + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: { + code: "BAD_REQUEST", + message: activeHitlError.message, + details: { + scopeCode: "SESSION_TOOL_BATCH_ACTIVE", + sessionId: "session-1", + hitlIds: ["hitl-1"], + }, + }, + }); + }); + test("returns a stable indeterminate command outcome without authorizing replay", async () => { const { app, project, runtime } = await createTestApp("command-indeterminate"); (runtime.acceptSessionMessage as unknown as ReturnType).mockImplementation(async () => { diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts index a9a05331..88c7ae6c 100644 --- a/apps/server/src/routes/messages.ts +++ b/apps/server/src/routes/messages.ts @@ -1,8 +1,11 @@ import { + SessionFamilyActiveError, + SessionFamilyStopInProgressError, SessionCommandConflictError, SessionCommandOutcomeError, SessionInputConflictError, SessionSteerUnavailableError, + SessionToolBatchActiveError, type AgentRuntime, } from "@archcode/agent-core"; import { @@ -203,19 +206,66 @@ function mapMessageMutationError(error: unknown, sessionId: string): unknown { ...(error.current === undefined ? {} : { current: error.current }), }); } - if (error instanceof SessionSteerUnavailableError || error instanceof SessionCommandConflictError) { - return new ServerError("BAD_REQUEST", error.message, 409, { - scopeCode: error.code, - sessionId: error.sessionId, + const errorCode = codedErrorValue(error, "code"); + if ( + error instanceof SessionFamilyActiveError + || error instanceof SessionFamilyStopInProgressError + || error instanceof SessionToolBatchActiveError + || errorCode === "SESSION_FAMILY_ACTIVE" + || errorCode === "SESSION_FAMILY_STOP_IN_PROGRESS" + || errorCode === "SESSION_TOOL_BATCH_ACTIVE" + ) { + return new ServerError("BAD_REQUEST", errorMessage(error), 409, { + scopeCode: errorCode, + sessionId: codedErrorValue(error, "sessionId") ?? sessionId, + ...(codedErrorValue(error, "rootSessionId") === undefined + ? {} + : { rootSessionId: codedErrorValue(error, "rootSessionId") }), + ...(codedStringArray(error, "hitlIds") === undefined + ? {} + : { hitlIds: codedStringArray(error, "hitlIds") }), }); } - if (error instanceof SessionCommandOutcomeError) { - return new ServerError("BAD_REQUEST", error.message, 409, { - scopeCode: error.code, - sessionId: error.sessionId, - clientRequestId: error.clientRequestId, - status: error.status, + if ( + error instanceof SessionSteerUnavailableError + || error instanceof SessionCommandConflictError + || errorCode === "SESSION_STEER_UNAVAILABLE" + || errorCode === "SESSION_COMMAND_CONFLICT" + ) { + return new ServerError("BAD_REQUEST", errorMessage(error), 409, { + scopeCode: errorCode, + sessionId: codedErrorValue(error, "sessionId") ?? sessionId, + }); + } + if ( + error instanceof SessionCommandOutcomeError + || errorCode === "SESSION_COMMAND_FAILED" + || errorCode === "SESSION_COMMAND_OUTCOME_INDETERMINATE" + ) { + return new ServerError("BAD_REQUEST", errorMessage(error), 409, { + scopeCode: errorCode, + sessionId: codedErrorValue(error, "sessionId") ?? sessionId, + clientRequestId: codedErrorValue(error, "clientRequestId"), + status: codedErrorValue(error, "status"), }); } return error; } + +function codedErrorValue(error: unknown, key: string): string | undefined { + if (typeof error !== "object" || error === null || !(key in error)) return undefined; + const value = Reflect.get(error, key); + return typeof value === "string" ? value : undefined; +} + +function codedStringArray(error: unknown, key: string): readonly string[] | undefined { + if (typeof error !== "object" || error === null || !(key in error)) return undefined; + const value = Reflect.get(error, key); + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? value + : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Session command could not be accepted"; +} diff --git a/apps/server/src/routes/sessions.test.ts b/apps/server/src/routes/sessions.test.ts index cdd6d5f3..5a830048 100644 --- a/apps/server/src/routes/sessions.test.ts +++ b/apps/server/src/routes/sessions.test.ts @@ -134,7 +134,7 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { const session = sessions.get(key); if (!session) throw new MissingSessionFileError(); if (session.parentSessionId !== undefined || session.rootSessionId !== session.sessionId) { - throw new SessionModelSelectionNotAllowedError(session.sessionId, "not_root_lead"); + throw new SessionModelSelectionNotAllowedError(session.sessionId, "not_user_facing_root"); } const revision = (modelSelectionRevisions.get(key) ?? input.expectedRevision) + 1; modelSelectionRevisions.set(key, revision); @@ -559,11 +559,11 @@ describe("sessions routes", () => { expect(await response.json()).toEqual({ error: { code: "BAD_REQUEST", - message: expect.stringContaining("root Lead Session"), + message: expect.stringContaining("user-facing root Session"), details: { scopeCode: "SESSION_MODEL_SELECTION_NOT_ALLOWED", sessionId: "child-session", - reason: "not_root_lead", + reason: "not_user_facing_root", }, }, }); diff --git a/apps/server/src/routes/todos.test.ts b/apps/server/src/routes/todos.test.ts index 0c30a37f..3d2b0cf9 100644 --- a/apps/server/src/routes/todos.test.ts +++ b/apps/server/src/routes/todos.test.ts @@ -81,6 +81,22 @@ describe("Project Todo routes", () => { entry, }); } + + const planDiscussion = await fixture.app.request(base, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expectedRevision: 1, + entry: "discussion", + initialIntent: "plan", + }), + }); + expect(planDiscussion.status).toBe(201); + expect(fixture.createSession).toHaveBeenLastCalledWith(todo.id, { + expectedRevision: 1, + entry: "discussion", + initialIntent: "plan", + }); }); test("strictly validates flat mutation and Session request bodies", async () => { @@ -103,10 +119,20 @@ describe("Project Todo routes", () => { headers, body: JSON.stringify({ expectedRevision: 1, entry: "invalid" }), }); + const invalidPlanIntent = await fixture.app.request(`${base}/${fixture.todo.id}/sessions`, { + method: "POST", + headers, + body: JSON.stringify({ + expectedRevision: 1, + entry: "work", + initialIntent: "plan", + }), + }); expect(emptyMutation.status).toBe(400); expect(mixedArchive.status).toBe(400); expect(invalidEntry.status).toBe(400); + expect(invalidPlanIntent.status).toBe(400); }); test("maps not-found and current-domain conflicts", async () => { diff --git a/apps/server/src/routes/todos.ts b/apps/server/src/routes/todos.ts index 52ad1f6d..d0e23658 100644 --- a/apps/server/src/routes/todos.ts +++ b/apps/server/src/routes/todos.ts @@ -47,6 +47,15 @@ const ProjectTodoUpdateBodySchema = z.strictObject({ const CreateProjectTodoSessionBodySchema = z.strictObject({ expectedRevision: z.number().int().positive(), entry: z.enum(["discussion", "work", "automation"]), + initialIntent: z.literal("plan").optional(), +}).superRefine((input, context) => { + if (input.initialIntent !== undefined && input.entry !== "discussion") { + context.addIssue({ + code: "custom", + path: ["initialIntent"], + message: "initialIntent is available only for Discussion Sessions", + }); + } }); export interface ProjectTodoServiceLike { diff --git a/apps/web/src/components/composite/ToolCard.test.tsx b/apps/web/src/components/composite/ToolCard.test.tsx index e0918807..277e98a2 100644 --- a/apps/web/src/components/composite/ToolCard.test.tsx +++ b/apps/web/src/components/composite/ToolCard.test.tsx @@ -237,7 +237,7 @@ describe("ToolCard strict result consumer", () => { }; const element = ToolCard({ part, projectSlug: "demo", sessionId: "root-1" }); expect(textContent(element)).toContain("Result unknown"); - expect(findByTestId(element, "tool-output-open")).toBeDefined(); + expect(textContent(findByTestId(element, "tool-output-open"))).toBe("View output"); }); test("collapses to the summary row and expands through the one disclosure control", () => { diff --git a/apps/web/src/components/composite/ToolCard.tsx b/apps/web/src/components/composite/ToolCard.tsx index 74038b08..57365b54 100644 --- a/apps/web/src/components/composite/ToolCard.tsx +++ b/apps/web/src/components/composite/ToolCard.tsx @@ -211,7 +211,7 @@ export function ToolCard({ part, projectSlug, sessionId, grouped = false }: Tool className="h-8 rounded-sm bg-brand-subtle px-3 text-[12px] font-medium text-brand transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" onClick={() => setViewerOpen((value) => !value)} > - {viewerOpen ? "关闭输出" : "查看输出"} + {viewerOpen ? "Hide output" : "View output"} expires {new Date(artifactRecovery.expiresAt).toLocaleString()} diff --git a/apps/web/src/lib/agent-constants.test.ts b/apps/web/src/lib/agent-constants.test.ts index de95c4f2..354d79eb 100644 --- a/apps/web/src/lib/agent-constants.test.ts +++ b/apps/web/src/lib/agent-constants.test.ts @@ -9,18 +9,19 @@ import { describe("AGENT_TYPES", () => { test("contains all expected agent types", () => { expect(AGENT_TYPES).toEqual([ - "lead", "analyst", "build", "explore", "librarian", + "lead", "discussion", "analyst", "build", "explore", "librarian", ]); }); test("is a readonly tuple", () => { - expect(AGENT_TYPES.length).toBe(5); + expect(AGENT_TYPES.length).toBe(6); }); }); describe("isValidAgentType", () => { test("returns true for valid agent types", () => { expect(isValidAgentType("lead")).toBe(true); + expect(isValidAgentType("discussion")).toBe(true); expect(isValidAgentType("analyst")).toBe(true); expect(isValidAgentType("build")).toBe(true); expect(isValidAgentType("explore")).toBe(true); diff --git a/apps/web/src/lib/agent-constants.ts b/apps/web/src/lib/agent-constants.ts index 9d948965..b82cba78 100644 --- a/apps/web/src/lib/agent-constants.ts +++ b/apps/web/src/lib/agent-constants.ts @@ -6,6 +6,7 @@ import type { AgentDescriptor } from "@archcode/protocol"; export const AGENT_TYPES = [ "lead", + "discussion", "analyst", "build", "explore", diff --git a/apps/web/src/routes/project-todos.interaction.tsx b/apps/web/src/routes/project-todos.interaction.tsx index 54c32285..04cf8029 100644 --- a/apps/web/src/routes/project-todos.interaction.tsx +++ b/apps/web/src/routes/project-todos.interaction.tsx @@ -4,16 +4,31 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { JSDOM } from "jsdom"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import type { ProjectTodo } from "../api/types"; -import { ProjectTodosRoute } from "./project-todos"; +import type { ProjectTodo, SessionSummary } from "../api/types"; import { WorkbenchLayoutProvider } from "../context/workbench-layout"; +const bootstrapDom = new JSDOM("", { + url: "http://localhost/projects/demo/todos", +}); +installDomGlobals(bootstrapDom); +const { + planWorkCommand, + ProjectTodosRoute, + TODO_PLAN_ACTION_LABEL, +} = await import("./project-todos"); +bootstrapDom.window.close(); + let dom: JSDOM; let root: Root; let client: QueryClient; let responseMode: "success" | "failure"; let patches: Array>; let fetchMock: ReturnType; +let sessionSummaries: SessionSummary[]; +let requests: Array<{ method: string; path: string; body?: unknown }>; +let sessionDetailResponse: () => Response | Promise; +let messageResponse: () => Response | Promise; +let createSessionResponse: () => Response | Promise; const todos: ProjectTodo[] = [ todo("idea", "Idea", "idea"), @@ -25,39 +40,72 @@ function todo(id: string, title: string, status: ProjectTodo["status"]): Project return { id, title, body: "", status, revision: 3, createdAt: 1, updatedAt: 1 }; } -beforeEach(() => { - dom = new JSDOM("
", { url: "http://localhost/projects/demo/todos" }); +function installDomGlobals(target: JSDOM): void { Object.assign(globalThis, { - window: dom.window, - document: dom.window.document, - navigator: dom.window.navigator, - HTMLElement: dom.window.HTMLElement, - Element: dom.window.Element, - Node: dom.window.Node, - Event: dom.window.Event, - CustomEvent: dom.window.CustomEvent, - MouseEvent: dom.window.MouseEvent, - TouchEvent: dom.window.TouchEvent, - KeyboardEvent: dom.window.KeyboardEvent, - MutationObserver: dom.window.MutationObserver, - getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + window: target.window, + document: target.window.document, + navigator: target.window.navigator, + HTMLElement: target.window.HTMLElement, + HTMLButtonElement: target.window.HTMLButtonElement, + HTMLInputElement: target.window.HTMLInputElement, + HTMLTextAreaElement: target.window.HTMLTextAreaElement, + Element: target.window.Element, + Node: target.window.Node, + NodeFilter: target.window.NodeFilter, + DocumentFragment: target.window.DocumentFragment, + Event: target.window.Event, + CustomEvent: target.window.CustomEvent, + MouseEvent: target.window.MouseEvent, + TouchEvent: target.window.TouchEvent, + KeyboardEvent: target.window.KeyboardEvent, + MutationObserver: target.window.MutationObserver, + getComputedStyle: target.window.getComputedStyle.bind(target.window), IS_REACT_ACT_ENVIRONMENT: true, }); +} + +beforeEach(() => { + dom = new JSDOM("
", { url: "http://localhost/projects/demo/todos" }); + installDomGlobals(dom); Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: class { observe() {} unobserve() {} disconnect() {} } }); Object.defineProperty(globalThis, "requestAnimationFrame", { configurable: true, value: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) }); Object.defineProperty(globalThis, "cancelAnimationFrame", { configurable: true, value: clearTimeout }); Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { configurable: true, value() { return rectFor(this); } }); responseMode = "success"; patches = []; + requests = []; + sessionSummaries = []; + sessionDetailResponse = () => Response.json({ + nextModelSelection: { + requested: { mode: "profile_default", selection: { model: "test:model" } }, + resolved: { + selection: { model: "test:model" }, + providerId: "test", + modelId: "model", + providerDisplayName: "Test", + modelDisplayName: "Model", + resolution: "profile_default", + modelRuntimeRevision: "runtime-1", + }, + }, + }); + messageResponse = () => Response.json({ clientRequestId: "plan-command", status: "command" }); + createSessionResponse = () => Response.json({ todo: todos[2], sessionId: "discussion-new" }); fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { const path = new URL(typeof input === "string" ? input : input.toString(), "http://localhost").pathname; - if (init?.method === "PATCH" && path.endsWith("/todos/idea")) { - patches.push(JSON.parse(String(init.body)) as Record); + const method = init?.method ?? "GET"; + const body = typeof init?.body === "string" ? JSON.parse(init.body) as unknown : undefined; + requests.push({ method, path, ...(body === undefined ? {} : { body }) }); + if (method === "PATCH" && path.endsWith("/todos/idea")) { + patches.push(body as Record); if (responseMode === "failure") return Response.json({ error: { code: "CONFLICT", message: "stale Todo" } }, { status: 409 }); return Response.json({ todo: { ...todos[0], status: "ready" } }); } + if (method === "POST" && path.endsWith("/messages")) return messageResponse(); + if (method === "POST" && path.endsWith("/sessions")) return createSessionResponse(); if (path.endsWith("/todos")) return Response.json({ todos }); - if (path.endsWith("/sessions")) return Response.json({ sessions: [] }); + if (path.endsWith("/sessions")) return Response.json({ sessions: sessionSummaries }); + if (path.includes("/sessions/")) return sessionDetailResponse(); if (path.endsWith("/automations")) return Response.json({ automations: [] }); return new Response("not found", { status: 404 }); }); @@ -72,9 +120,9 @@ afterEach(async () => { dom.window.close(); }); -async function render(): Promise { +async function render(initialEntry = "/projects/demo/todos"): Promise { await act(async () => { - root.render(} />); + root.render(} />} />); }); await settle(); } @@ -83,6 +131,66 @@ async function settle(): Promise { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await settle(); + } + throw new Error("Timed out waiting for the Todo interaction state"); +} + +async function renderSelectedTodo(): Promise { + await render("/projects/demo/todos?todo=ready"); + await waitFor(() => document.querySelector('[data-testid="todo-detail-drawer"]') !== null); +} + +function addDiscussionSummary(sessionId = "discussion-latest"): void { + sessionSummaries.push({ + sessionId, + rootSessionId: sessionId, + cwd: "/tmp", + agentName: "discussion", + profile: "principal", + activeSkillNames: ["shape-todo"], + modelSelection: { revision: 0 }, + title: "Plan discussion", + projectTodo: { todoId: "ready", entry: "discussion" }, + createdAt: 2, + updatedAt: 3, + }); +} + +function findPlanButton(): HTMLButtonElement { + const button = Array.from(document.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes(TODO_PLAN_ACTION_LABEL), + ); + if (!(button instanceof dom.window.HTMLButtonElement)) { + throw new Error("Plan action button was not rendered"); + } + return button; +} + +function findActionGroup(label: string): HTMLElement { + const group = [...document.querySelectorAll('[role="group"]')].find((candidate) => + candidate.getAttribute("aria-label") === label + ); + if (!(group instanceof dom.window.HTMLElement)) { + throw new Error(`Action group "${label}" was not rendered`); + } + return group; +} + +function actionGroupButtonLabels(label: string): string[] { + return [...findActionGroup(label).querySelectorAll("button")].map((button) => button.textContent?.trim() ?? ""); +} + +async function click(button: HTMLButtonElement): Promise { + await act(async () => { + button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + }); + await settle(); +} + function rectFor(element: Element): DOMRect { const testId = element.getAttribute("data-testid"); const laneXs = { idea: 0, ready: 300, in_progress: 600, done: 900 }; @@ -228,3 +336,189 @@ describe("Project Todos drag interactions", () => { expect(patches).toEqual([{ expectedRevision: 3, status: "ready", beforeTodoId: null }]); }); }); + +describe("Project Todos Plan interactions", () => { + test("groups drawer actions by intent without changing their lifecycle availability", async () => { + addDiscussionSummary(); + await renderSelectedTodo(); + + const content = document.querySelector('[aria-label="Todo content"]'); + const actions = document.querySelector('[aria-label="Todo actions"]'); + const groups = [...actions!.querySelectorAll(':scope > div > [role="group"]')]; + + expect([...content!.querySelectorAll("button")].map((button) => button.textContent?.trim())).toEqual(["Edit"]); + expect(groups.map((group) => group.getAttribute("aria-label"))).toEqual([ + "Discuss & Plan", + "Execution", + "Lifecycle", + ]); + expect(actionGroupButtonLabels("Discuss & Plan")).toEqual([ + "Continue Discussion", + "New Discussion", + TODO_PLAN_ACTION_LABEL, + ]); + expect(actionGroupButtonLabels("Execution")).toEqual([ + "Start Work", + "Create Automation", + ]); + expect(actionGroupButtonLabels("Lifecycle")).toEqual([ + "Reject", + "Move to Ideas", + "Move to In Progress", + "Move to Done", + "Archive", + ]); + }); + + test("renders the responsive drawer controls and creates one atomic Plan Discussion when none exists", async () => { + await renderSelectedTodo(); + + const drawer = document.querySelector('[data-testid="todo-detail-drawer"]'); + const close = document.querySelector('[aria-label="Close Todo details"]'); + const planButton = findPlanButton(); + expect(drawer?.className).toContain("w-[min(430px,calc(100%_-_18px))]"); + expect(close?.className).toContain("focus-visible:ring-2"); + expect(close?.className).toContain("[@media(pointer:coarse)]:h-11"); + expect(planButton.className).toContain("[@media(pointer:coarse)]:min-h-11"); + expect(planButton.disabled).toBe(false); + + await click(planButton); + + const creates = requests.filter((request) => + request.method === "POST" && request.path.endsWith("/todos/ready/sessions"), + ); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + expectedRevision: 3, + entry: "discussion", + initialIntent: "plan", + }); + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/messages"), + )).toHaveLength(0); + }); + + test("keeps Plan available for a busy Discussion and creates one atomic replacement", async () => { + addDiscussionSummary(); + sessionDetailResponse = () => Response.json({ + currentExecutionId: "execution-active", + nextModelSelection: { + requested: { mode: "profile_default", selection: { model: "test:model" } }, + }, + }); + await renderSelectedTodo(); + + const planButton = findPlanButton(); + expect(planButton.disabled).toBe(false); + await click(planButton); + + const creates = requests.filter((request) => + request.method === "POST" && request.path.endsWith("/todos/ready/sessions"), + ); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ entry: "discussion", initialIntent: "plan" }); + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/messages"), + )).toHaveLength(0); + }); + + test("reuses an idle Discussion with exactly one Plan command", async () => { + addDiscussionSummary(); + await renderSelectedTodo(); + + await click(findPlanButton()); + + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/todos/ready/sessions"), + )).toHaveLength(0); + const messages = requests.filter((request) => + request.method === "POST" && request.path.endsWith("/messages"), + ); + expect(messages).toHaveLength(1); + expect(messages[0]?.body).toMatchObject({ + text: planWorkCommand("ready"), + attachmentIds: [], + requestedModelSelection: { + mode: "profile_default", + selection: { model: "test:model" }, + }, + }); + }); + + test("falls back to a new Plan Discussion when idle reuse loses the acceptance race", async () => { + addDiscussionSummary(); + messageResponse = () => Response.json({ + error: { + code: "BAD_REQUEST", + message: "The Session is waiting for a tool response.", + details: { + scopeCode: "SESSION_TOOL_BATCH_ACTIVE", + sessionId: "discussion-latest", + }, + }, + }, { status: 409 }); + await renderSelectedTodo(); + + await click(findPlanButton()); + + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/messages"), + )).toHaveLength(1); + const creates = requests.filter((request) => + request.method === "POST" && request.path.endsWith("/todos/ready/sessions"), + ); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ entry: "discussion", initialIntent: "plan" }); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }); + + test("falls back to a new Plan Discussion when the linked Session is stale", async () => { + addDiscussionSummary(); + sessionDetailResponse = () => Response.json({ + error: { code: "NOT_FOUND", message: "Session not found" }, + }, { status: 404 }); + await renderSelectedTodo(); + + await click(findPlanButton()); + + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/todos/ready/sessions"), + )).toHaveLength(1); + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/messages"), + )).toHaveLength(0); + }); + + test("shows local progress, blocks a duplicate click, and recovers beside the Plan action", async () => { + addDiscussionSummary(); + let resolveMessage: ((response: Response) => void) | undefined; + messageResponse = () => new Promise((resolve) => { + resolveMessage = resolve; + }); + await renderSelectedTodo(); + + const planButton = findPlanButton(); + await act(async () => { + planButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + planButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + }); + await waitFor(() => planButton.textContent?.includes("Opening Plan…") === true); + + expect(planButton.disabled).toBe(true); + expect(planButton.querySelector(".animate-activity")).not.toBeNull(); + expect(requests.filter((request) => + request.method === "POST" && request.path.endsWith("/messages"), + )).toHaveLength(1); + + await act(async () => { + resolveMessage?.(Response.json({ + error: { code: "INTERNAL_ERROR", message: "Plan service unavailable" }, + }, { status: 500 })); + }); + await waitFor(() => planButton.disabled === false); + + expect(planButton.textContent).toContain(TODO_PLAN_ACTION_LABEL); + const error = findActionGroup("Discuss & Plan").querySelector('[role="alert"]'); + expect(error?.textContent).toBe("Plan service unavailable"); + }); +}); diff --git a/apps/web/src/routes/project-todos.test.tsx b/apps/web/src/routes/project-todos.test.tsx index ba513403..225feeed 100644 --- a/apps/web/src/routes/project-todos.test.tsx +++ b/apps/web/src/routes/project-todos.test.tsx @@ -4,13 +4,14 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { JSDOM } from "jsdom"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import type { ProjectTodo } from "../api/types"; -import { continueWorkUpdateInput, createDragAnnouncements, deriveProjectTodoGroups, moveTodoInBoard, ProjectTodosRoute } from "./project-todos"; +import type { ProjectTodo, SessionSummary } from "../api/types"; +import { continueWorkUpdateInput, coordinateTodoPlanWork, createDragAnnouncements, deriveProjectTodoGroups, moveTodoInBoard, planWorkCommand, pointerFirstCollisionDetection, ProjectTodosRoute, TODO_PLAN_ACTION_LABEL } from "./project-todos"; import { WorkbenchLayoutProvider } from "../context/workbench-layout"; let dom: JSDOM; let root: Root; let client: QueryClient; +let sessionSummaries: SessionSummary[]; const todos: ProjectTodo[] = [ todo("idea", "Idea", "idea"), @@ -25,6 +26,19 @@ function todo(id: string, title: string, status: ProjectTodo["status"]): Project } beforeEach(() => { + sessionSummaries = [{ + sessionId: "work-0", + rootSessionId: "work-0", + cwd: "/tmp", + agentName: "lead", + profile: "principal", + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: "Existing work", + projectTodo: { todoId: "ready", entry: "work" }, + createdAt: 1, + updatedAt: 2, + }]; dom = new JSDOM("
", { url: "http://localhost/projects/demo/todos?todo=ready" }); Object.assign(globalThis, { window: dom.window, document: dom.window.document, navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, Node: dom.window.Node, Event: dom.window.Event, CustomEvent: dom.window.CustomEvent, MouseEvent: dom.window.MouseEvent, KeyboardEvent: dom.window.KeyboardEvent, MutationObserver: dom.window.MutationObserver, getComputedStyle: dom.window.getComputedStyle.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true }); Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: class { observe() {} unobserve() {} disconnect() {} } }); @@ -32,9 +46,31 @@ beforeEach(() => { Object.defineProperty(globalThis, "cancelAnimationFrame", { configurable: true, value: clearTimeout }); Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async (input: RequestInfo | URL, init?: RequestInit) => { const path = new URL(typeof input === "string" ? input : input.toString(), "http://localhost").pathname; - if (init?.method === "POST" && path.endsWith("/sessions")) return Response.json({ todo: todos[1], sessionId: "work-1" }); + const method = init?.method ?? "GET"; + if (method === "POST" && path.endsWith("/messages")) { + return Response.json({ clientRequestId: "plan-command", status: "command" }); + } + if (method === "POST" && path.endsWith("/sessions")) { + return Response.json({ todo: todos[1], sessionId: "discussion-new" }); + } if (path.endsWith("/todos")) return Response.json({ todos }); - if (path.endsWith("/sessions")) return Response.json({ sessions: [{ sessionId: "work-0", rootSessionId: "work-0", cwd: "/tmp", agentName: "lead", profile: "principal", activeSkillNames: [], modelSelection: {}, title: "Existing work", projectTodo: { todoId: "ready", entry: "work" }, createdAt: 1, updatedAt: 2 }] }); + if (path.endsWith("/sessions")) return Response.json({ sessions: sessionSummaries }); + if (path.includes("/sessions/")) { + return Response.json({ + nextModelSelection: { + requested: { mode: "profile_default", selection: { model: "test:model" } }, + resolved: { + selection: { model: "test:model" }, + providerId: "test", + modelId: "model", + providerDisplayName: "Test", + modelDisplayName: "Model", + resolution: "profile_default", + modelRuntimeRevision: "runtime-1", + }, + }, + }); + } if (path.endsWith("/automations")) return Response.json({ automations: [{ id: "auto-1", projectSlug: "demo", createdFromSessionId: "work-0", projectTodoId: "ready", name: "Nightly", trigger: { kind: "once", at: "2026-01-01" }, action: { kind: "start_session", message: "go", location: "project" }, status: "active", createdAt: "2026-01-01", updatedAt: "2026-01-02" }] }); return new Response("not found", { status: 404 }); }) }); @@ -100,4 +136,225 @@ describe("Project Todos board", () => { expect(document.querySelector('[data-testid="todo-open-ready"]')?.className).toContain("cursor-pointer"); }); + test("uses the pointer position to target another lane on a narrow board", () => { + const leftLane = rect(0, 0, 120, 500); + const leftCard = rect(0, 0, 120, 100); + const rightLane = rect(140, 0, 120, 500); + const rightCard = rect(140, 0, 120, 100); + const droppableRects = new Map([ + ["idea", leftLane], + ["left-card", leftCard], + ["ready", rightLane], + ["right-card", rightCard], + ]); + const droppableContainers = Array.from(droppableRects, ([id, currentRect]) => ({ + id, + key: id, + data: { current: {} }, + disabled: false, + node: { current: null }, + rect: { current: currentRect }, + })); + const collisions = pointerFirstCollisionDetection({ + active: { + id: "right-card", + data: { current: {} }, + rect: { current: { initial: rightCard, translated: rightCard } }, + }, + collisionRect: rightCard, + droppableRects, + droppableContainers, + pointerCoordinates: { x: 60, y: 300 }, + }); + + expect(collisions[0]?.id).toBe("idea"); + }); + + test("does not infer a pointer target from the dragged card in a lane gap", () => { + const leftLane = rect(0, 0, 120, 500); + const rightLane = rect(140, 0, 120, 500); + const dragged = rect(140, 0, 120, 100); + const droppableRects = new Map([ + ["idea", leftLane], + ["ready", rightLane], + ]); + const droppableContainers = Array.from(droppableRects, ([id, currentRect]) => ({ + id, + key: id, + data: { current: {} }, + disabled: false, + node: { current: null }, + rect: { current: currentRect }, + })); + + expect(pointerFirstCollisionDetection({ + active: { + id: "right-card", + data: { current: {} }, + rect: { current: { initial: dragged, translated: dragged } }, + }, + collisionRect: dragged, + droppableRects, + droppableContainers, + pointerCoordinates: { x: 130, y: 300 }, + })).toEqual([]); + + expect(pointerFirstCollisionDetection({ + active: { + id: "right-card", + data: { current: {} }, + rect: { current: { initial: dragged, translated: dragged } }, + }, + collisionRect: dragged, + droppableRects, + droppableContainers, + pointerCoordinates: null, + })[0]?.id).toBe("ready"); + }); + + test("builds one deterministic plan-work command for the Todo", () => { + expect(TODO_PLAN_ACTION_LABEL).toBe("Generate / Improve Plan"); + expect(planWorkCommand("ready")).toContain("/skill use plan-work"); + expect(planWorkCommand("ready")).toContain(".archcode/plans/ready.md"); + expect(planWorkCommand("ready")).toContain("do not start implementation"); + }); + + test("reuses an existing Discussion for Plan work without creating another", async () => { + const created: string[] = []; + const sent: Array<{ sessionId: string; command: string }> = []; + const opened: string[] = []; + const sessionId = await coordinateTodoPlanWork({ + todoId: "ready", + existingDiscussionSessionId: "discussion-latest", + createPlanDiscussion: async () => { + created.push("called"); + return "discussion-new"; + }, + loadExistingDiscussion: async () => ({ + isBusy: false, + requestedModelSelection: { + mode: "profile_default", + selection: { model: "test:model" }, + }, + }), + sendCommand: async (targetSessionId, command) => { + sent.push({ sessionId: targetSessionId, command }); + return "sent"; + }, + openSession: (targetSessionId) => opened.push(targetSessionId), + }); + + expect(sessionId).toBe("discussion-latest"); + expect(created).toEqual([]); + expect(sent).toEqual([{ + sessionId: "discussion-latest", + command: planWorkCommand("ready"), + }]); + expect(opened).toEqual(["discussion-latest"]); + }); + + test("creates a Discussion with atomic initial Plan work when none exists", async () => { + const sentTo: string[] = []; + const opened: string[] = []; + const sessionId = await coordinateTodoPlanWork({ + todoId: "ready", + createPlanDiscussion: async () => "discussion-new", + loadExistingDiscussion: async () => ({ + isBusy: false, + requestedModelSelection: { + mode: "profile_default", + selection: { model: "test:model" }, + }, + }), + sendCommand: async (targetSessionId, command) => { + sentTo.push(`${targetSessionId}:${command}`); + return "sent"; + }, + openSession: (targetSessionId) => opened.push(targetSessionId), + }); + + expect(sessionId).toBe("discussion-new"); + expect(sentTo).toEqual([]); + expect(opened).toEqual(["discussion-new"]); + }); + + test("starts a new Plan Discussion when the existing Discussion is busy", async () => { + const created: string[] = []; + const sent: string[] = []; + const opened: string[] = []; + + const sessionId = await coordinateTodoPlanWork({ + todoId: "ready", + existingDiscussionSessionId: "discussion-running", + createPlanDiscussion: async () => { + created.push("called"); + return "discussion-new"; + }, + loadExistingDiscussion: async () => ({ + isBusy: true, + requestedModelSelection: { + mode: "profile_default", + selection: { model: "test:model" }, + }, + }), + sendCommand: async (sessionId) => { + sent.push(sessionId); + return "sent"; + }, + openSession: (sessionId) => opened.push(sessionId), + }); + + expect(sessionId).toBe("discussion-new"); + expect(created).toEqual(["called"]); + expect(sent).toEqual([]); + expect(opened).toEqual(["discussion-new"]); + }); + + test("starts a new Plan Discussion when the linked Discussion disappears", async () => { + const opened: string[] = []; + const sessionId = await coordinateTodoPlanWork({ + todoId: "ready", + existingDiscussionSessionId: "discussion-missing", + createPlanDiscussion: async () => "discussion-new", + loadExistingDiscussion: async () => undefined, + sendCommand: async () => "sent", + openSession: (targetSessionId) => opened.push(targetSessionId), + }); + + expect(sessionId).toBe("discussion-new"); + expect(opened).toEqual(["discussion-new"]); + }); + + test("starts a new Plan Discussion when an idle reuse loses the acceptance race", async () => { + const opened: string[] = []; + const sessionId = await coordinateTodoPlanWork({ + todoId: "ready", + existingDiscussionSessionId: "discussion-raced", + createPlanDiscussion: async () => "discussion-new", + loadExistingDiscussion: async () => ({ + isBusy: false, + requestedModelSelection: { + mode: "profile_default", + selection: { model: "test:model" }, + }, + }), + sendCommand: async () => "unavailable", + openSession: (targetSessionId) => opened.push(targetSessionId), + }); + + expect(sessionId).toBe("discussion-new"); + expect(opened).toEqual(["discussion-new"]); + }); + }); + +function rect(left: number, top: number, width: number, height: number) { + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + }; +} diff --git a/apps/web/src/routes/project-todos.tsx b/apps/web/src/routes/project-todos.tsx index e3e52d59..5115dbfb 100644 --- a/apps/web/src/routes/project-todos.tsx +++ b/apps/web/src/routes/project-todos.tsx @@ -1,5 +1,6 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { useQueryClient } from "@tanstack/react-query"; import { DndContext, DragOverlay, @@ -7,9 +8,11 @@ import { PointerSensor, TouchSensor, closestCorners, + pointerWithin, useDroppable, useSensor, useSensors, + type CollisionDetection, type DragEndEvent, type DragOverEvent, type DragStartEvent, @@ -22,10 +25,12 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import type { RequestedModelSelection } from "@archcode/protocol"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { Archive, Check, GripVertical, MessageCircle, Plus, RotateCcw, Save, Send, X } from "lucide-react"; -import { useCreateProjectTodo, useCreateProjectTodoSession, useUpdateProjectTodo } from "../api/mutations"; -import { useAutomations, useProjectTodos, useSessions } from "../api/queries"; +import { Archive, Check, FileText, GripVertical, LoaderCircle, MessageCircle, Plus, RotateCcw, Save, Send, X } from "lucide-react"; +import { ApiError } from "../api/client"; +import { useCreateProjectTodo, useCreateProjectTodoSession, usePostMessage, useUpdateProjectTodo } from "../api/mutations"; +import { sessionQueryOptions, useAutomations, useProjectTodos, useSessions } from "../api/queries"; import type { Automation, ProjectTodo, ProjectTodoStatus, ProjectTodoUpdateInput, SessionSummary } from "../api/types"; import { STATUS_TONE_CLASS, type StatusTone } from "../lib/status-visuals"; import { @@ -38,6 +43,7 @@ import { SidebarToggleButton } from "../components/features/SidebarToggleButton" type View = "board" | "rejected" | "archived"; type BoardOrder = Record; const LANES: readonly ProjectTodoLane[] = ["idea", "ready", "in_progress", "done"]; +export const TODO_PLAN_ACTION_LABEL = "Generate / Improve Plan"; export function deriveProjectTodoGroups(todos: readonly ProjectTodo[]): Record { const groups: Record = { idea: [], ready: [], in_progress: [], done: [] }; @@ -71,12 +77,83 @@ export function moveTodoInBoard(order: BoardOrder, todoId: string, destination: return next; } +/** + * Pointer and touch drags target what the user is actually pointing at. + * Keyboard drags have no pointer coordinates, so they retain geometric + * collision detection. + */ +export const pointerFirstCollisionDetection: CollisionDetection = (args) => { + return args.pointerCoordinates === null + ? closestCorners(args) + : pointerWithin(args); +}; + export function continueWorkUpdateInput(todo: ProjectTodo): ProjectTodoUpdateInput | undefined { return todo.status === "ready" ? { expectedRevision: todo.revision, status: "in_progress" } : undefined; } +export function planWorkCommand(todoId: string): string { + return `/skill use plan-work Create or improve the implementation Plan for this bound Todo at .archcode/plans/${todoId}.md. Preserve one Plan file, read it before editing when it exists, and do not start implementation.`; +} + +export async function coordinateTodoPlanWork(input: { + todoId: string; + existingDiscussionSessionId?: string; + createPlanDiscussion: () => Promise; + loadExistingDiscussion: (sessionId: string) => Promise<{ + isBusy: boolean; + requestedModelSelection: RequestedModelSelection; + } | undefined>; + sendCommand: ( + sessionId: string, + command: string, + selection: RequestedModelSelection, + ) => Promise<"sent" | "unavailable">; + openSession: (sessionId: string) => void; +}): Promise { + const createAndOpen = async (): Promise => { + const sessionId = await input.createPlanDiscussion(); + input.openSession(sessionId); + return sessionId; + }; + + if (input.existingDiscussionSessionId === undefined) { + return createAndOpen(); + } + const sessionId = input.existingDiscussionSessionId; + const existing = await input.loadExistingDiscussion(sessionId); + if (existing === undefined || existing.isBusy) { + return createAndOpen(); + } + const disposition = await input.sendCommand( + sessionId, + planWorkCommand(input.todoId), + existing.requestedModelSelection, + ); + if (disposition === "unavailable") { + return createAndOpen(); + } + input.openSession(sessionId); + return sessionId; +} + +const PLAN_DISCUSSION_CONFLICT_CODES = new Set([ + "SESSION_FAMILY_ACTIVE", + "SESSION_FAMILY_STOP_IN_PROGRESS", + "SESSION_TOOL_BATCH_ACTIVE", + "SESSION_COMMAND_CONFLICT", +]); + +function isUnavailablePlanDiscussion(cause: unknown): boolean { + if (!(cause instanceof ApiError)) return false; + if (cause.status === 404) return true; + if (cause.status !== 409 || typeof cause.details !== "object" || cause.details === null) return false; + const scopeCode = Reflect.get(cause.details, "scopeCode"); + return typeof scopeCode === "string" && PLAN_DISCUSSION_CONFLICT_CODES.has(scopeCode); +} + export function ProjectTodosRoute() { const { slug = "" } = useParams<{ slug: string }>(); const navigate = useNavigate(); @@ -218,7 +295,7 @@ export function ProjectTodosRoute() {
{view === "board" ? ( - +
{LANES.map((lane) => )}
@@ -263,16 +340,21 @@ function TodoFlatList({ view, todos, selectedId, onSelect }: { view: Exclude; onClose: () => void; createSession: ReturnType; updateTodo: ReturnType }) { if (!todo) return null; - return { if (!open) onClose(); }}>; + return { if (!open) onClose(); }}>; } function TodoDetailPanel({ todo, slug, sessions, automations, navigate, createSession, updateTodo }: { todo: ProjectTodo; slug: string; sessions: SessionSummary[]; automations: Automation[]; navigate: ReturnType; createSession: ReturnType; updateTodo: ReturnType }) { + const queryClient = useQueryClient(); + const postMessage = usePostMessage(); const [editing, setEditing] = useState(false); const [title, setTitle] = useState(todo.title); const [body, setBody] = useState(todo.body); const [reason, setReason] = useState(""); const [rejecting, setRejecting] = useState(false); const [actionError, setActionError] = useState(null); + const [planError, setPlanError] = useState(null); + const [isOpeningPlan, setIsOpeningPlan] = useState(false); + const planActionInFlight = useRef(false); const associatedSessions = sessions.filter((session) => session.projectTodo?.todoId === todo.id).sort((left, right) => right.updatedAt - left.updatedAt); const discussionSessions = associatedSessions.filter((session) => session.projectTodo?.entry === "discussion"); const workSessions = associatedSessions.filter((session) => session.projectTodo?.entry === "work"); @@ -287,6 +369,65 @@ function TodoDetailPanel({ todo, slug, sessions, automations, navigate, createSe setActionError(null); createSession.mutate({ slug, todoId: todo.id, input: { expectedRevision: todo.revision, entry } }, { onSuccess: ({ sessionId }) => navigate(`/projects/${slug}/sessions/${sessionId}`), onError: (cause) => setActionError(messageFor(cause)) }); }; + const openPlan = async () => { + if (planActionInFlight.current) return; + planActionInFlight.current = true; + setPlanError(null); + setIsOpeningPlan(true); + try { + await coordinateTodoPlanWork({ + todoId: todo.id, + ...(discussionSessions[0]?.sessionId === undefined + ? {} + : { existingDiscussionSessionId: discussionSessions[0].sessionId }), + createPlanDiscussion: async () => (await createSession.mutateAsync({ + slug, + todoId: todo.id, + input: { + expectedRevision: todo.revision, + entry: "discussion", + initialIntent: "plan", + }, + })).sessionId, + loadExistingDiscussion: async (sessionId) => { + try { + const session = await queryClient.fetchQuery({ + ...sessionQueryOptions(slug, sessionId), + staleTime: 0, + }); + return { + isBusy: session.currentExecutionId !== undefined, + requestedModelSelection: session.nextModelSelection.requested, + }; + } catch (cause) { + if (isUnavailablePlanDiscussion(cause)) return undefined; + throw cause; + } + }, + sendCommand: async (sessionId, content, requestedModelSelection) => { + try { + await postMessage.mutateAsync({ + slug, + sessionId, + content, + attachmentIds: [], + requestedModelSelection, + }); + return "sent"; + } catch (cause) { + if (isUnavailablePlanDiscussion(cause)) return "unavailable"; + throw cause; + } + }, + openSession: (sessionId) => navigate(`/projects/${slug}/sessions/${sessionId}`), + }); + } catch (cause) { + setPlanError(messageFor(cause)); + } finally { + planActionInFlight.current = false; + setIsOpeningPlan(false); + } + }; const reject = () => { const rejectionReason = reason.trim(); if (!rejectionReason) { setActionError("Rejection reason is required"); return; } @@ -303,11 +444,114 @@ function TodoDetailPanel({ todo, slug, sessions, automations, navigate, createSe } navigateToSession(); }; - return <>
{presentation.label}{todo.title}A Todo can source multiple discussions, work sessions, and automations.
{editing ?
setTitle(event.target.value)} className="h-8 w-full rounded-sm border border-border-control bg-bg-base px-3 text-[12px]" />