diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 5fb140b60157a6f2622a0e0099e1272711882fcf..25ce8773bd77ce3fd4dbb81c9dd6941a5e9ff8b1 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -413,6 +413,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }) : missingSessionEffect(provider, threadId); + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( + threadId, + _requestId, + _answers, + ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId)); + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { sessions.delete(threadId); @@ -475,6 +481,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 4657fdaad868eb026924380fff7d63a10c8049b7..606087f0ac35bae6525d32f407a2ef5073183c2d 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { ApprovalRequestId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, @@ -118,6 +119,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: harness.workspaceDir, @@ -143,6 +145,7 @@ const startTurn = (input: { attachments: [], }, ...(input.provider !== undefined ? { provider: input.provider } : {}), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: nowIso(), }); @@ -241,6 +244,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( projectId: PROJECT_ID, title: "Integration Thread", model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: harness.workspaceDir, @@ -257,6 +261,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( text: "Reply with exactly ALPHA.", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", createdAt: nowIso(), }); @@ -283,6 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( text: "Reply with exactly BETA.", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: nowIso(), }); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 3961c3a48454ab8676c69ae6026d731e881007ae..dce489cbb9f15125c23e214a51773dbd1ef68136 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -1,5 +1,6 @@ import { CheckpointRef, + DEFAULT_PROVIDER_INTERACTION_MODE, ProjectId, ThreadId, TurnId, @@ -43,6 +44,7 @@ function makeSnapshot(input: { projectId: input.projectId, title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: input.worktreePath, diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 2f6c6d5ab7200849a5c594b20877411433b8c777..ff716d418c72a59d26d0c6a1720b58633fdfbf73 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { ThreadId } from "@t3tools/contracts"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { + buildCodexInitializeParams, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, CodexAppServerManager, classifyCodexStderrLine, isRecoverableThreadResumeError, @@ -22,6 +24,7 @@ function createSendTurnHarness() { provider: "codex", status: "ready", threadId: "thread_1", + resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, @@ -58,6 +61,7 @@ function createThreadControlHarness() { provider: "codex", status: "ready", threadId: "thread_1", + resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, @@ -80,6 +84,46 @@ function createThreadControlHarness() { return { manager, context, requireSession, sendRequest, updateSession }; } +function createPendingUserInputHarness() { + const manager = new CodexAppServerManager(); + const context = { + session: { + sessionId: "sess_1", + provider: "codex", + status: "ready", + threadId: "thread_1", + resumeCursor: { threadId: "thread_1" }, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + pendingUserInputs: new Map([ + [ + ApprovalRequestId.makeUnsafe("req-user-input-1"), + { + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + jsonRpcId: 42, + threadId: asThreadId("thread_1"), + }, + ], + ]), + }; + + const requireSession = vi + .spyOn( + manager as unknown as { requireSession: (sessionId: string) => unknown }, + "requireSession", + ) + .mockReturnValue(context); + const writeMessage = vi + .spyOn(manager as unknown as { writeMessage: (...args: unknown[]) => void }, "writeMessage") + .mockImplementation(() => {}); + const emitEvent = vi + .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") + .mockImplementation(() => {}); + + return { manager, context, requireSession, writeMessage, emitEvent }; +} + describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); @@ -151,6 +195,19 @@ describe("isRecoverableThreadResumeError", () => { }); describe("startSession", () => { + it("enables Codex experimental api capabilities during initialize", () => { + expect(buildCodexInitializeParams()).toEqual({ + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + }); + }); + it("emits session/startFailed when resolving cwd throws before process launch", async () => { const manager = new CodexAppServerManager(); const events: Array<{ method: string; kind: string; message?: string }> = []; @@ -207,6 +264,7 @@ describe("sendTurn", () => { expect(result).toEqual({ threadId: "thread_1", turnId: "turn_1", + resumeCursor: { threadId: "thread_1" }, }); expect(requireSession).toHaveBeenCalledWith("thread_1"); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { @@ -228,6 +286,7 @@ describe("sendTurn", () => { expect(updateSession).toHaveBeenCalledWith(context, { status: "running", activeTurnId: "turn_1", + resumeCursor: { threadId: "thread_1" }, }); }); @@ -255,6 +314,36 @@ describe("sendTurn", () => { }); }); + it("passes Codex plan mode as a collaboration preset on turn/start", async () => { + const { manager, context, sendRequest } = createSendTurnHarness(); + + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "Plan the work", + interactionMode: "plan", + }); + + expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { + threadId: "thread_1", + input: [ + { + type: "text", + text: "Plan the work", + text_elements: [], + }, + ], + model: "gpt-5.3-codex", + collaborationMode: { + mode: "plan", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "medium", + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + }, + }, + }); + }); + it("rejects empty turn input", async () => { const { manager } = createSendTurnHarness(); @@ -354,6 +443,45 @@ describe("thread checkpoint control", () => { }); }); +describe("respondToUserInput", () => { + it("serializes canonical answers to Codex native answer objects", async () => { + const { manager, context, requireSession, writeMessage, emitEvent } = + createPendingUserInputHarness(); + + await manager.respondToUserInput( + asThreadId("thread_1"), + ApprovalRequestId.makeUnsafe("req-user-input-1"), + { + scope: "All request methods", + compat: "Keep current envelope", + }, + ); + + expect(requireSession).toHaveBeenCalledWith("thread_1"); + expect(writeMessage).toHaveBeenCalledWith(context, { + id: 42, + result: { + answers: { + scope: { answers: ["All request methods"] }, + compat: { answers: ["Keep current envelope"] }, + }, + }, + }); + expect(emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + method: "item/tool/requestUserInput/answered", + payload: { + requestId: "req-user-input-1", + answers: { + scope: { answers: ["All request methods"] }, + compat: { answers: ["Keep current envelope"] }, + }, + }, + }), + ); + }); +}); + describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { it( "keeps prior thread history when resuming with a changed runtime mode", diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index b159b2a53a379c31591d10a6067024f25f612906..f75772691cdb4c9627c2bc54f9160245c591affb 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -8,6 +8,7 @@ import { EventId, ProviderItemId, ProviderRequestKind, + type ProviderUserInputAnswers, ThreadId, TurnId, normalizeModelSlug, @@ -38,12 +39,25 @@ interface PendingApprovalRequest { itemId?: ProviderItemId; } +interface PendingUserInputRequest { + requestId: ApprovalRequestId; + jsonRpcId: string | number; + threadId: ThreadId; + turnId?: TurnId; + itemId?: ProviderItemId; +} + +interface CodexUserInputAnswer { + answers: string[]; +} + interface CodexSessionContext { session: ProviderSession; child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; pendingApprovals: Map; + pendingUserInputs: Map; nextRequestId: number; stopping: boolean; } @@ -76,6 +90,7 @@ export interface CodexAppServerSendTurnInput { readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; readonly effort?: string; + readonly interactionMode?: "default" | "plan"; } export interface CodexThreadTurnSnapshot { @@ -103,6 +118,127 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; +export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) + +You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. + +## Mode rules (strict) + +You are in **Plan Mode** until a developer message explicitly ends it. + +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. + +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`\` block. + +Separately, \`update_plan\` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use \`update_plan\` in Plan mode, it will return an error. + +## Execution vs. mutation in Plan Mode + +You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. + +### Allowed (non-mutating, plan-improving) + +Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: + +* Reading or searching files, configs, schemas, types, manifests, and docs +* Static analysis, inspection, and repo exploration +* Dry-run style commands when they do not edit repo-tracked files +* Tests, builds, or checks that may write to caches or build artifacts (for example, \`target/\`, \`.cache/\`, or snapshots) so long as they do not edit repo-tracked files + +### Not allowed (mutating, plan-executing) + +Actions that implement the plan or change repo-tracked state. Examples: + +* Editing or writing files +* Running formatters or linters that rewrite files +* Applying patches, migrations, or codegen that updates repo-tracked files +* Side-effectful commands whose purpose is to carry out the plan rather than refine it + +When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. + +## PHASE 1 - Ground in the environment (explore first, ask second) + +Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. + +Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. + +Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. + +Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. + +## PHASE 2 - Intent chat (what they actually want) + +* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. +* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. + +## PHASE 3 - Implementation chat (what/how we'll build) + +* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. + +## Asking questions + +Critical rules: + +* Strongly prefer using the \`request_user_input\` tool to ask any questions. +* Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. +* In rare cases where an unavoidable, important question can't be expressed with reasonable multiple-choice options (due to extreme ambiguity), you may ask it directly without the tool. + +You SHOULD ask many questions, but each question must: + +* materially change the spec/plan, OR +* confirm/lock an assumption, OR +* choose between meaningful tradeoffs. +* not be answerable by non-mutating commands. + +Use the \`request_user_input\` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. + +## Two kinds of unknowns (treat differently) + +1. **Discoverable facts** (repo/system truth): explore first. + + * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. + * If asking, present concrete candidates (paths/service names) + recommend one. + * Never ask questions you can answer from your environment (e.g., "where is this struct"). + +2. **Preferences/tradeoffs** (not discoverable): ask early. + + * These are intent or implementation preferences that cannot be derived from exploration. + * Provide 2-4 mutually exclusive options + a recommended default. + * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. + +## Finalization rule + +Only output the final plan when it is decision complete and leaves no decisions to the implementer. + +When you present the official plan, wrap it in a \`\` block so the client can render it specially: + +1) The opening tag must be on its own line. +2) Start the plan content on the next line (no text on the same line as the tag). +3) The closing tag must be on its own line. +4) Use Markdown inside the block. +5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. + +Example: + + +plan content + + +plan content should be human and agent digestible. The final plan must be plan-only and include: + +* A clear title +* A brief summary section +* Important changes or additions to public APIs/interfaces/types +* Test cases and scenarios +* Explicit assumptions and defaults chosen where needed + +Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. + +Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +`; function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): { readonly approvalPolicy: "on-request" | "never"; @@ -154,6 +290,80 @@ export function normalizeCodexModelSlug( return normalized; } +export function buildCodexInitializeParams() { + return { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + } as const; +} + +function buildCodexCollaborationMode(input: { + readonly interactionMode?: "default" | "plan"; + readonly model?: string; + readonly effort?: string; +}): + | { + mode: "plan"; + settings: { + model: string; + reasoning_effort: string; + developer_instructions: string; + }; + } + | undefined { + if (input.interactionMode !== "plan") { + return undefined; + } + const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex"; + return { + mode: "plan", + settings: { + model, + reasoning_effort: input.effort ?? "medium", + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + }, + }; +} + +function toCodexUserInputAnswer(value: unknown): CodexUserInputAnswer { + if (typeof value === "string") { + return { answers: [value] }; + } + + if (Array.isArray(value)) { + const answers = value.filter((entry): entry is string => typeof entry === "string"); + if (answers.length > 0) { + return { answers }; + } + } + + if (value && typeof value === "object") { + const maybeAnswers = (value as { answers?: unknown }).answers; + if (Array.isArray(maybeAnswers)) { + const answers = maybeAnswers.filter((entry): entry is string => typeof entry === "string"); + if (answers.length > 0) { + return { answers }; + } + } + } + + throw new Error("User input answers must be strings or arrays of strings."); +} + +function toCodexUserInputAnswers( + answers: ProviderUserInputAnswers, +): Record { + return Object.fromEntries( + Object.entries(answers).map(([questionId, value]) => [questionId, toCodexUserInputAnswer(value)]), + ); +} + export function classifyCodexStderrLine(rawLine: string): { message: string } | null { const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); if (!line) { @@ -237,6 +447,7 @@ export class CodexAppServerManager extends EventEmitter; model?: string; effort?: string; + collaborationMode?: { + mode: "plan"; + settings: { + model: string; + reasoning_effort: string; + developer_instructions: string; + }; + }; } = { threadId: providerThreadId, input: turnInput, }; - const normalizedModel = normalizeCodexModelSlug(input.model); + const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model); if (normalizedModel) { turnStartParams.model = normalizedModel; } if (input.effort) { turnStartParams.effort = input.effort; } + const collaborationMode = buildCodexCollaborationMode({ + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(normalizedModel !== undefined ? { model: normalizedModel } : {}), + ...(input.effort !== undefined ? { effort: input.effort } : {}), + }); + if (collaborationMode) { + if (!turnStartParams.model) { + turnStartParams.model = collaborationMode.settings.model; + } + turnStartParams.collaborationMode = collaborationMode; + } const response = await this.sendRequest(context, "turn/start", turnStartParams); @@ -561,6 +782,43 @@ export class CodexAppServerManager extends EventEmitter { + const context = this.requireSession(threadId); + const pendingRequest = context.pendingUserInputs.get(requestId); + if (!pendingRequest) { + throw new Error(`Unknown pending user input request: ${requestId}`); + } + + context.pendingUserInputs.delete(requestId); + const codexAnswers = toCodexUserInputAnswers(answers); + this.writeMessage(context, { + id: pendingRequest.jsonRpcId, + result: { + answers: codexAnswers, + }, + }); + + this.emitEvent({ + id: EventId.makeUnsafe(randomUUID()), + kind: "notification", + provider: "codex", + threadId: context.session.threadId, + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput/answered", + turnId: pendingRequest.turnId, + itemId: pendingRequest.itemId, + requestId: pendingRequest.requestId, + payload: { + requestId: pendingRequest.requestId, + answers: codexAnswers, + }, + }); + } + stopSession(threadId: ThreadId): void { const context = this.sessions.get(threadId); if (!context) { @@ -575,6 +833,7 @@ export class CodexAppServerManager extends EventEmitter unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), @@ -286,6 +288,7 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: options?.threadWorktreePath ?? cwd, @@ -601,6 +604,7 @@ describe("CheckpointReactor", () => { text: "start turn", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: new Date().toISOString(), }), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 8abb526a910582754a641f2a635cc19564757105..181b18d60cf752808b8405b18a57bae056497cfc 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1,6 +1,7 @@ import { CheckpointRef, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, ProjectId, ThreadId, @@ -80,6 +81,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -97,6 +99,7 @@ describe("OrchestrationEngine", () => { text: "hello", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt, }), @@ -132,6 +135,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "replay", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -193,7 +197,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "domain-stream", model: "gpt-5-codex", - runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -237,6 +242,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -345,7 +351,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-fail", model: "gpt-5-codex", - runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -361,6 +368,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-ok", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -425,6 +433,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "atomic", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -442,6 +451,7 @@ describe("OrchestrationEngine", () => { text: "hello", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required" as const, createdAt, }; @@ -558,6 +568,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "sync-before", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -602,6 +613,7 @@ describe("OrchestrationEngine", () => { text: "hello", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now(), }), @@ -636,6 +648,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -652,7 +665,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", - runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8daaeb3285e25738b1481bcc0630bb8c6ab23bd6..cd8111fbcd13dd83b529a8c6f3b21a86f582b7ad 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -397,6 +397,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { title: event.payload.title, model: event.payload.model, runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, latestTurnId: null, @@ -441,6 +442,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + case "thread.interaction-mode-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRow = yield* projectionThreadRepository.getById({ diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ab6e3c15c1849c9562acc9640eaa1fedcecb7251..23074a741986ef5d00b3639ba9cc5100169f1ffc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { projectId: asProjectId("project-1"), title: "Thread 1", model: "gpt-5-codex", + interactionMode: "default", runtimeMode: "full-access", branch: null, worktreePath: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 772e543f7e67b5e4a97e9decd1df04b261991b00..8af14b5ed0a973895f83dbe0e5b77a657ea6cd10 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, model, runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", @@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, model: row.model, runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, latestTurn: latestTurnByThread.get(row.threadId) ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5498d4fc246d790637c8a1582571a06981ee0e87..503b61d4e5966577828a827ff488c29637200047 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -6,6 +6,7 @@ import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, @@ -139,6 +140,7 @@ describe("ProviderCommandReactor", () => { ); const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn(() => Effect.void); + const respondToUserInput = vi.fn(() => Effect.void); const stopSession = vi.fn((input: unknown) => Effect.sync(() => { const threadId = @@ -180,6 +182,7 @@ describe("ProviderCommandReactor", () => { sendTurn: sendTurn as ProviderServiceShape["sendTurn"], interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], + respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (provider) => @@ -234,6 +237,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -247,6 +251,7 @@ describe("ProviderCommandReactor", () => { sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, renameBranch, generateBranchName, @@ -269,6 +274,7 @@ describe("ProviderCommandReactor", () => { text: "hello reactor", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -289,6 +295,44 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("forwards plan interaction mode to the provider turn request", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.makeUnsafe("cmd-interaction-mode-set-plan"), + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-plan"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-plan"), + role: "user", + text: "plan this change", + attachments: [], + }, + interactionMode: "plan", + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + }); + }); + it("starts first turn with requested provider when provider is specified", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -305,6 +349,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -341,6 +386,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, provider: "cursor", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -376,6 +422,7 @@ describe("ProviderCommandReactor", () => { text: "first", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -395,6 +442,7 @@ describe("ProviderCommandReactor", () => { text: "second", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -422,6 +470,7 @@ describe("ProviderCommandReactor", () => { }, provider: "cursor", model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -443,6 +492,7 @@ describe("ProviderCommandReactor", () => { }, provider: "cursor", model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -470,6 +520,7 @@ describe("ProviderCommandReactor", () => { }, provider: "cursor", model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -491,6 +542,7 @@ describe("ProviderCommandReactor", () => { }, provider: "cursor", model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -531,6 +583,7 @@ describe("ProviderCommandReactor", () => { text: "first", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", createdAt: now, }), @@ -566,6 +619,7 @@ describe("ProviderCommandReactor", () => { text: "second", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", createdAt: now, }), @@ -604,6 +658,7 @@ describe("ProviderCommandReactor", () => { text: "first", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -624,6 +679,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -672,6 +728,7 @@ describe("ProviderCommandReactor", () => { text: "first", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", createdAt: now, }), @@ -746,7 +803,6 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.interruptTurn.mock.calls.length === 1); expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ threadId: "thread-1", - turnId: "turn-1", }); }); @@ -791,6 +847,51 @@ describe("ProviderCommandReactor", () => { }); }); + it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.makeUnsafe("cmd-user-input-respond"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + sandbox_mode: "workspace-write", + }, + createdAt: now, + }), + ); + + await waitFor(() => harness.respondToUserInput.mock.calls.length === 1); + expect(harness.respondToUserInput.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + requestId: "user-input-request-1", + answers: { + sandbox_mode: "workspace-write", + }, + }); + }); + it("surfaces stale provider approval request failures without faking approval resolution", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cc4e5f20f273880efef7b8285cabf0dd9597aeb7..7856865865bf7cecb9e498b18db2e33680857eb2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -31,6 +31,7 @@ type ProviderIntentEvent = Extract< | "thread.turn-start-requested" | "thread.turn-interrupt-requested" | "thread.approval-response-requested" + | "thread.user-input-response-requested" | "thread.session-stop-requested"; } >; @@ -147,6 +148,7 @@ const make = Effect.gen(function* () { | "provider.turn.start.failed" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" + | "provider.user-input.respond.failed" | "provider.session.stop.failed"; readonly summary: string; readonly detail: string; @@ -320,6 +322,7 @@ const make = Effect.gen(function* () { readonly provider?: ProviderKind; readonly model?: string; readonly effort?: string; + readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); @@ -348,6 +351,7 @@ const make = Effect.gen(function* () { ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); }); @@ -461,6 +465,7 @@ const make = Effect.gen(function* () { ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), + interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); }); @@ -535,6 +540,50 @@ const make = Effect.gen(function* () { ); }); + const processUserInputResponseRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: "No active provider session is bound to this thread.", + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + } + + yield* providerService + .respondToUserInput({ + threadId: event.payload.threadId, + requestId: event.payload.requestId, + answers: event.payload.answers, + }) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const error = Cause.squash(cause); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: toErrorMessage(error), + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + }), + ), + ); + }); + const processSessionStopRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -583,6 +632,9 @@ const make = Effect.gen(function* () { case "thread.approval-response-requested": yield* processApprovalResponseRequested(event); return; + case "thread.user-input-response-requested": + yield* processUserInputResponseRequested(event); + return; case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; @@ -617,6 +669,7 @@ const make = Effect.gen(function* () { event.type !== "thread.turn-start-requested" && event.type !== "thread.turn-interrupt-requested" && event.type !== "thread.approval-response-requested" && + event.type !== "thread.user-input-response-requested" && event.type !== "thread.session-stop-requested" ) { return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 569907de7ea626104522b2d6cdc2cc3afbddf257..8fd64abd0203dc15c2d194845c9cd039b02e2be2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,6 +2,7 @@ import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/cont import { ApprovalRequestId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, @@ -59,6 +60,7 @@ function createProviderServiceHarness() { sendTurn: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), @@ -163,6 +165,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -630,6 +633,112 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("projects completed plan items into finalized proposed-plan assistant messages", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-plan-item-completed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-final"), + itemId: asItemId("item-plan-final"), + payload: { + itemType: "plan", + status: "completed", + detail: "## Ship plan\n\n- wire projection\n- render follow-up", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => { + const message = entry.messages.find( + (candidate: ProviderRuntimeTestMessage) => candidate.id === "assistant:item-plan-final", + ); + return message?.streaming === false && message.text.includes(""); + }); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-plan-final", + ); + expect(message?.text).toBe( + "\n## Ship plan\n\n- wire projection\n- render follow-up\n", + ); + expect(message?.streaming).toBe(false); + }); + + it("finalizes buffered plan deltas into a proposed-plan assistant message on turn completion", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-plan-buffer"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-plan-delta-1"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + itemId: asItemId("item-plan-buffer"), + payload: { + streamKind: "plan_text", + delta: "## Buffered plan\n\n- first", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-plan-delta-2"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + itemId: asItemId("item-plan-buffer"), + payload: { + streamKind: "plan_text", + delta: "\n- second", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-plan-buffer"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + payload: { + state: "completed", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => { + const message = entry.messages.find( + (candidate: ProviderRuntimeTestMessage) => candidate.id === "assistant:item-plan-buffer", + ); + return message?.streaming === false && message.text.includes(""); + }); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-plan-buffer", + ); + expect(message?.text).toBe( + "\n## Buffered plan\n\n- first\n- second\n", + ); + expect(message?.streaming).toBe(false); + }); + it("buffers assistant deltas by default until completion", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -716,6 +825,7 @@ describe("ProviderRuntimeIngestion", () => { attachments: [], }, assistantDeliveryMode: "streaming", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -1217,6 +1327,160 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("projects Codex task lifecycle chunks into thread activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-task-started"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + taskId: "turn-task-1", + taskType: "plan", + }, + }); + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-task-progress"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + taskId: "turn-task-1", + description: "Comparing the desktop rollout chunks to the app-server stream.", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-task-completed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + taskId: "turn-task-1", + status: "completed", + summary: "\n# Plan title\n", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed", + ), + ); + + const started = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", + ); + const progress = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress", + ); + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", + ); + + const progressPayload = + progress?.payload && typeof progress.payload === "object" + ? (progress.payload as Record) + : undefined; + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(started?.kind).toBe("task.started"); + expect(started?.summary).toBe("Plan task started"); + expect(progress?.kind).toBe("task.progress"); + expect(progressPayload?.detail).toBe( + "Comparing the desktop rollout chunks to the app-server stream.", + ); + expect(completed?.kind).toBe("task.completed"); + expect(completedPayload?.detail).toBe("\n# Plan title\n"); + }); + + it("projects structured user input request and resolution as thread activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "user-input.requested", + eventId: asEventId("evt-user-input-requested"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-user-input"), + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }); + + harness.emit({ + type: "user-input.resolved", + eventId: asEventId("evt-user-input-resolved"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-user-input"), + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + answers: { + sandbox_mode: "workspace-write", + }, + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.resolved", + ), + ); + + const requested = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-requested", + ); + expect(requested?.kind).toBe("user-input.requested"); + + const resolved = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-resolved", + ); + const resolvedPayload = + resolved?.payload && typeof resolved.payload === "object" + ? (resolved.payload as Record) + : undefined; + expect(resolved?.kind).toBe("user-input.resolved"); + expect(resolvedPayload?.answers).toEqual({ + sandbox_mode: "workspace-write", + }); + }); + it("continues processing runtime events after a single event handler failure", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index f78e7d1e99a9687d31c2fc164b3f61ed1a28d1d2..6ab4653b293872105512073603c3744d7f11f52a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -26,6 +26,8 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); +const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; +const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; @@ -65,6 +67,14 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } +function wrapProposedPlanMessage(planMarkdown: string | undefined): string | undefined { + const trimmed = planMarkdown?.trim(); + if (!trimmed) { + return undefined; + } + return `\n${trimmed}\n`; +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -168,6 +178,9 @@ function runtimeEventToActivities( })(); switch (event.type) { case "request.opened": { + if (event.payload.requestType === "tool_user_input") { + return []; + } const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); return [ { @@ -196,6 +209,9 @@ function runtimeEventToActivities( } case "request.resolved": { + if (event.payload.requestType === "tool_user_input") { + return []; + } const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); return [ { @@ -273,6 +289,111 @@ function runtimeEventToActivities( ]; } + case "user-input.requested": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + ...(event.requestId ? { requestId: event.requestId } : {}), + questions: event.payload.questions, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "user-input.resolved": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "user-input.resolved", + summary: "User input submitted", + payload: { + ...(event.requestId ? { requestId: event.requestId } : {}), + answers: event.payload.answers, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.started": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "task.started", + summary: + event.payload.taskType === "plan" + ? "Plan task started" + : event.payload.taskType + ? `${event.payload.taskType} task started` + : "Task started", + payload: { + taskId: event.payload.taskId, + ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), + ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.progress": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "task.progress", + summary: "Reasoning update", + payload: { + taskId: event.payload.taskId, + detail: truncateDetail(event.payload.description), + ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.completed": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: event.payload.status === "failed" ? "error" : "info", + kind: "task.completed", + summary: + event.payload.status === "failed" + ? "Task failed" + : event.payload.status === "stopped" + ? "Task stopped" + : "Task completed", + payload: { + taskId: event.payload.taskId, + status: event.payload.status, + ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "item.updated": { if (!isToolLifecycleItemType(event.payload.itemType)) { return []; @@ -359,6 +480,12 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(new Set()), }); + const messageStreamKindByMessageId = yield* Cache.make({ + capacity: MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed("assistant_text"), + }); + const bufferedAssistantTextByMessageId = yield* Cache.make({ capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, @@ -418,6 +545,16 @@ const make = Effect.gen(function* () { const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); + const rememberMessageStreamKind = ( + messageId: MessageId, + streamKind: "assistant_text" | "plan_text", + ) => Cache.set(messageStreamKindByMessageId, messageId, streamKind); + + const getMessageStreamKind = (messageId: MessageId) => + Cache.getOption(messageStreamKindByMessageId, messageId).pipe( + Effect.map((streamKind) => Option.getOrElse(streamKind, () => "assistant_text" as const)), + ); + const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => @@ -451,7 +588,10 @@ const make = Effect.gen(function* () { Cache.invalidate(bufferedAssistantTextByMessageId, messageId); const clearAssistantMessageState = (messageId: MessageId) => - clearBufferedAssistantText(messageId); + Effect.all([ + clearBufferedAssistantText(messageId), + Cache.invalidate(messageStreamKindByMessageId, messageId), + ]).pipe(Effect.asVoid); const finalizeAssistantMessage = (input: { event: ProviderRuntimeEvent; @@ -464,9 +604,20 @@ const make = Effect.gen(function* () { fallbackText?: string; }) => Effect.gen(function* () { + const messageStreamKind = yield* getMessageStreamKind(input.messageId); const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const bufferedFinalText = + bufferedText.length > 0 + ? messageStreamKind === "plan_text" + ? (wrapProposedPlanMessage(bufferedText) ?? "") + : bufferedText + : ""; const text = - bufferedText.length > 0 ? bufferedText : (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : ""; + bufferedFinalText.length > 0 + ? bufferedFinalText + : (input.fallbackText?.trim().length ?? 0) > 0 + ? input.fallbackText! + : ""; if (text.length > 0) { yield* orchestrationEngine.dispatch({ @@ -617,7 +768,8 @@ const make = Effect.gen(function* () { } const assistantDelta = - event.type === "content.delta" && event.payload.streamKind === "assistant_text" + event.type === "content.delta" && + (event.payload.streamKind === "assistant_text" || event.payload.streamKind === "plan_text") ? event.payload.delta : undefined; @@ -626,9 +778,14 @@ const make = Effect.gen(function* () { `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); const turnId = toTurnId(event.turnId); + const messageStreamKind = + event.type === "content.delta" && event.payload.streamKind === "plan_text" + ? "plan_text" + : "assistant_text"; if (turnId) { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } + yield* rememberMessageStreamKind(assistantMessageId, messageStreamKind); const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); if (assistantDeliveryMode === "buffered") { @@ -658,12 +815,16 @@ const make = Effect.gen(function* () { } const assistantCompletion = - event.type === "item.completed" && event.payload.itemType === "assistant_message" + event.type === "item.completed" && + (event.payload.itemType === "assistant_message" || event.payload.itemType === "plan") ? { messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), - fallbackText: event.payload.detail, + fallbackText: + event.payload.itemType === "plan" + ? wrapProposedPlanMessage(event.payload.detail) + : event.payload.detail, } - : undefined; + : undefined; if (assistantCompletion) { const assistantMessageId = assistantCompletion.messageId; diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 37dac36dd4c6d80a5c7cb4ba950d058bf1ebbd77..9a6922c56d0f36e09e05df05e4d6ffbacfba8ca0 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -5,6 +5,7 @@ import { ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, + ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -26,6 +27,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; +export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 971a41e5d6cb52e07c881ed3e123b15eef1b5d7e..ae2ec25f9793be345e162ace7b07a17ad0b78d04 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { MessageId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, ProjectId, ThreadId, type OrchestrationCommand, @@ -50,6 +51,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -67,6 +69,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -92,6 +95,7 @@ const messageSendCommand: OrchestrationCommand = { text: "hello", attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }; @@ -139,6 +143,7 @@ describe("commandInvariants", () => { projectId: ProjectId.makeUnsafe("project-a"), title: "new", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -159,6 +164,7 @@ describe("commandInvariants", () => { projectId: ProjectId.makeUnsafe("project-a"), title: "dup", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: null, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 2b109bad0a0fe24827f5ea2efdc8063f51e90e0e..9a806bdc9bb49c7ee9907ea7b76956a66edba44f 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -1,4 +1,11 @@ -import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { Effect } from "effect"; @@ -130,6 +137,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, worktreePath: null, @@ -154,6 +162,7 @@ describe("decider project scripts", () => { provider: "claudeCode", model: "gpt-5", effort: "high", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }, @@ -225,6 +234,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -259,4 +269,83 @@ describe("decider project scripts", () => { }, }); }); + + it("emits thread.interaction-mode-set from thread.interaction-mode.set", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-1"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.interaction-mode.set", + commandId: CommandId.makeUnsafe("cmd-interaction-mode-set"), + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + createdAt: now, + }, + readModel, + }), + ); + + const singleResult = Array.isArray(result) ? null : result; + if (singleResult === null) { + throw new Error("Expected a single interaction-mode-set event."); + } + expect(singleResult).toMatchObject({ + type: "thread.interaction-mode-set", + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + }, + }); + }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index dd5a34882a4bbc98d88efc6e5f63e92cc46a50e0..48afbcea40b0654bde83a31def418c1a757026d5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -158,6 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, model: command.model, runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, createdAt: command.createdAt, @@ -237,6 +238,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.interaction-mode.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.interaction-mode-set", + payload: { + threadId: command.threadId, + interactionMode: command.interactionMode, + updatedAt: occurredAt, + }, + }; + } + case "thread.turn.start": { yield* requireThread({ readModel, @@ -282,6 +306,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? command.runtimeMode, + interactionMode: + readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ?? + command.interactionMode, createdAt: command.createdAt, }, }; @@ -336,6 +363,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.user-input.respond": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: { + requestId: command.requestId, + }, + }), + type: "thread.user-input-response-requested", + payload: { + threadId: command.threadId, + requestId: command.requestId, + answers: command.answers, + createdAt: command.createdAt, + }, + }; + } + case "thread.checkpoint.revert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 8861d038fc4c48a2d27f1d021116231b60948aa2..af4af5e2cd8bdcced51ca8bec45e50758d8c8eff 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -16,6 +16,7 @@ import { ThreadActivityAppendedPayload, ThreadCreatedPayload, ThreadDeletedPayload, + ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadRuntimeModeSetPayload, ThreadRevertedPayload, @@ -243,6 +244,7 @@ export function projectEvent( title: payload.title, model: payload.model, runtimeMode: payload.runtimeMode, + interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, latestTurn: null, @@ -307,6 +309,22 @@ export function projectEvent( })), ); + case "thread.interaction-mode-set": + return decodeForEvent( + ThreadInteractionModeSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + interactionMode: payload.interactionMode, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.message-sent": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 5b33c495bb51a56da2b2d4e56601ea0dd9095523..10192697d074b806ef0578985dfb39d3af220798 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -25,6 +25,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { title, model, runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, @@ -38,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.title}, ${row.model}, ${row.runtimeMode}, + ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, ${row.latestTurnId}, @@ -51,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { title = excluded.title, model = excluded.model, runtime_mode = excluded.runtime_mode, + interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, latest_turn_id = excluded.latest_turn_id, @@ -71,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { title, model, runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", @@ -93,6 +97,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { title, model, runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ebe7ae214a0aa558040434abd387dcf21f18cf27..83836a7973299a5e55997455cf9723ca082d76e2 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -23,6 +23,7 @@ import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts" import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; +import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import { Effect } from "effect"; /** @@ -47,6 +48,7 @@ const loader = Migrator.fromRecord({ "9_ProviderSessionRuntimeMode": Migration0009, "10_ProjectionThreadsRuntimeMode": Migration0010, "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, + "12_ProjectionThreadsInteractionMode": Migration0012, }); /** diff --git a/apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts b/apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts new file mode 100644 index 0000000000000000000000000000000000000000..60695ac6cc748adef0c9c199842847ee4f991ab0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN interaction_mode TEXT NOT NULL DEFAULT 'default' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 5421bfdf7b9b7e3d076bdc5b2611be8961d7fad6..7a30870f2d64a0dbf24b9c6c1ec901fc954d6f47 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -6,7 +6,14 @@ * * @module ProjectionThreadRepository */ -import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts"; +import { + IsoDateTime, + ProjectId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -18,6 +25,7 @@ export const ProjectionThread = Schema.Struct({ title: Schema.String, model: Schema.String, runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 31ea53739f20075ff3eca0d8d6989e74316a0f33..2606f33ff06c5a7a35d9ff89bb9378be106f561f 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -1781,6 +1781,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Deferred.succeed(pending.decision, decision); }); + const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/requestUserInput", + detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); @@ -1830,6 +1843,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { readThread, rollbackThread, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index e435af2afdde6165413abfbcdc12468d401bc8f4..8d76583413be7d123861470193fbea74bcfccff9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -7,6 +7,7 @@ import { type ProviderSession, type ProviderSessionStartInput, type ProviderTurnStartResult, + type ProviderUserInputAnswers, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -76,6 +77,14 @@ class FakeCodexManager extends CodexAppServerManager { ): Promise => undefined, ); + public respondToUserInputImpl = vi.fn( + async ( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _answers: ProviderUserInputAnswers, + ): Promise => undefined, + ); + public stopAllImpl = vi.fn(() => undefined); override startSession(input: ProviderSessionStartInput): Promise { @@ -106,6 +115,14 @@ class FakeCodexManager extends CodexAppServerManager { return this.respondToRequestImpl(threadId, requestId, decision); } + override respondToUserInput( + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + ): Promise { + return this.respondToUserInputImpl(threadId, requestId, answers); + } + override stopSession(_threadId: ThreadId): void {} override listSessions(): ProviderSession[] { @@ -253,6 +270,46 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps completed plan items to canonical item.completed plan events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + const event: ProviderEvent = { + id: asEventId("evt-plan-complete"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("plan_1"), + payload: { + item: { + type: "Plan", + id: "plan_1", + text: "## Final plan\n\n- one\n- two", + }, + }, + }; + + lifecycleManager.emit("event", event); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "item.completed"); + if (firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.itemId, "plan_1"); + assert.equal(firstEvent.value.payload.itemType, "plan"); + assert.equal(firstEvent.value.payload.detail, "## Final plan\n\n- one\n- two"); + }), + ); + it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -361,6 +418,182 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { } }), ); + + it.effect("maps requestUserInput requests and answered notifications to canonical user-input events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + + lifecycleManager.emit("event", { + id: asEventId("evt-user-input-requested"), + kind: "request", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput", + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + } satisfies ProviderEvent); + lifecycleManager.emit("event", { + id: asEventId("evt-user-input-resolved"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput/answered", + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + answers: { + sandbox_mode: { + answers: ["workspace-write"], + }, + }, + }, + } satisfies ProviderEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.equal(events[0]?.type, "user-input.requested"); + if (events[0]?.type === "user-input.requested") { + assert.equal(events[0].requestId, "req-user-input-1"); + assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); + } + + assert.equal(events[1]?.type, "user-input.resolved"); + if (events[1]?.type === "user-input.resolved") { + assert.equal(events[1].requestId, "req-user-input-1"); + assert.deepEqual(events[1].payload.answers, { + sandbox_mode: "workspace-write", + }); + } + }), + ); + + it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe( + Effect.forkChild, + ); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-task-started"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/task_started", + payload: { + id: "turn-structured-1", + msg: { + type: "task_started", + turn_id: "turn-structured-1", + collaboration_mode_kind: "plan", + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-agent-reasoning"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/agent_reasoning", + payload: { + id: "turn-structured-1", + msg: { + type: "agent_reasoning", + text: "Need to compare both transport layers before finalizing the plan.", + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-reasoning-delta"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/reasoning_content_delta", + payload: { + id: "turn-structured-1", + msg: { + type: "reasoning_content_delta", + turn_id: "turn-structured-1", + item_id: "rs_reasoning_1", + delta: "**Compare** transport boundaries", + summary_index: 0, + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-task-complete"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/task_complete", + payload: { + id: "turn-structured-1", + msg: { + type: "task_complete", + turn_id: "turn-structured-1", + last_agent_message: "\n# Ship it\n", + }, + }, + } satisfies ProviderEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + + assert.equal(events[0]?.type, "task.started"); + if (events[0]?.type === "task.started") { + assert.equal(events[0].turnId, "turn-structured-1"); + assert.equal(events[0].payload.taskId, "turn-structured-1"); + assert.equal(events[0].payload.taskType, "plan"); + } + + assert.equal(events[1]?.type, "task.progress"); + if (events[1]?.type === "task.progress") { + assert.equal(events[1].payload.taskId, "turn-structured-1"); + assert.equal( + events[1].payload.description, + "Need to compare both transport layers before finalizing the plan.", + ); + } + + assert.equal(events[2]?.type, "content.delta"); + if (events[2]?.type === "content.delta") { + assert.equal(events[2].turnId, "turn-structured-1"); + assert.equal(events[2].itemId, "rs_reasoning_1"); + assert.equal(events[2].payload.streamKind, "reasoning_summary_text"); + assert.equal(events[2].payload.summaryIndex, 0); + } + + assert.equal(events[3]?.type, "task.completed"); + if (events[3]?.type === "task.completed") { + assert.equal(events[3].turnId, "turn-structured-1"); + assert.equal(events[3].payload.taskId, "turn-structured-1"); + assert.equal(events[3].payload.summary, "\n# Ship it\n"); + } + }), + ); }); afterAll(() => { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index bc1367434ead465d7f6d68fc0f32ce37df54ba85..d4558a8fd235d39930d2893e4d980a947a3bfbe0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -11,11 +11,14 @@ import { type CanonicalRequestType, type ProviderEvent, type ProviderRuntimeEvent, + type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, ProviderApprovalDecision, ProviderItemId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; @@ -95,6 +98,10 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -249,6 +256,88 @@ function toRequestTypeFromResolvedPayload( return "unknown"; } +function toCanonicalUserInputAnswers( + answers: ProviderUserInputAnswers | undefined, +): ProviderUserInputAnswers { + if (!answers) { + return {}; + } + + return Object.fromEntries( + Object.entries(answers).flatMap(([questionId, value]) => { + if (typeof value === "string") { + return [[questionId, value] as const]; + } + + if (Array.isArray(value)) { + const normalized = value.filter((entry): entry is string => typeof entry === "string"); + if (normalized.length === 0) { + return []; + } + return [[questionId, normalized.length === 1 ? normalized[0] : normalized] as const]; + } + + const answerObject = asObject(value); + const answerList = asArray(answerObject?.answers)?.filter( + (entry): entry is string => typeof entry === "string", + ); + if (!answerList || answerList.length === 0) { + return []; + } + return [[questionId, answerList.length === 1 ? answerList[0] : answerList] as const]; + }), + ); +} + +function toUserInputQuestions(payload: Record | undefined) { + const questions = asArray(payload?.questions); + if (!questions) { + return undefined; + } + + const parsedQuestions = questions + .map((entry) => { + const question = asObject(entry); + if (!question) return undefined; + const options = asArray(question.options) + ?.map((option) => { + const optionRecord = asObject(option); + if (!optionRecord) return undefined; + const label = asString(optionRecord.label)?.trim(); + const description = asString(optionRecord.description)?.trim(); + if (!label || !description) { + return undefined; + } + return { label, description }; + }) + .filter((option): option is { label: string; description: string } => option !== undefined); + const id = asString(question.id)?.trim(); + const header = asString(question.header)?.trim(); + const prompt = asString(question.question)?.trim(); + if (!id || !header || !prompt || !options || options.length === 0) { + return undefined; + } + return { + id, + header, + question: prompt, + options, + }; + }) + .filter( + ( + question, + ): question is { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + } => question !== undefined, + ); + + return parsedQuestions.length > 0 ? parsedQuestions : undefined; +} + function toThreadState( value: unknown, ): "active" | "idle" | "archived" | "closed" | "compacted" | "error" { @@ -304,6 +393,46 @@ function asRuntimeRequestId(requestId: string): RuntimeRequestId { return RuntimeRequestId.makeUnsafe(requestId); } +function asRuntimeTaskId(taskId: string): RuntimeTaskId { + return RuntimeTaskId.makeUnsafe(taskId); +} + +function codexEventMessage(payload: Record | undefined): Record | undefined { + return asObject(payload?.msg); +} + +function codexEventBase( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): Omit { + const payload = asObject(event.payload); + const msg = codexEventMessage(payload); + const turnId = asString(msg?.turn_id) ?? asString(msg?.turnId); + const itemId = asString(msg?.item_id) ?? asString(msg?.itemId); + const requestId = asString(msg?.request_id) ?? asString(msg?.requestId); + const base = runtimeEventBase(event, canonicalThreadId); + const providerRefs = base.providerRefs + ? { + ...base.providerRefs, + ...(turnId ? { providerTurnId: turnId } : {}), + ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(requestId ? { providerRequestId: requestId } : {}), + } + : { + ...(turnId ? { providerTurnId: turnId } : {}), + ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(requestId ? { providerRequestId: requestId } : {}), + }; + + return { + ...base, + ...(turnId ? { turnId: TurnId.makeUnsafe(turnId) } : {}), + ...(itemId ? { itemId: asRuntimeItemId(ProviderItemId.makeUnsafe(itemId)) } : {}), + ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}), + ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}), + }; +} + function eventRawSource(event: ProviderEvent): NonNullable["source"] { return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification"; } @@ -404,6 +533,22 @@ function mapToRuntimeEvents( } if (event.kind === "request") { + if (event.method === "item/tool/requestUserInput") { + const questions = toUserInputQuestions(payload); + if (!questions) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "user-input.requested", + payload: { + questions, + }, + }, + ]; + } + const detail = asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt); return [ @@ -737,6 +882,102 @@ function mapToRuntimeEvents( ]; } + if (event.method === "item/tool/requestUserInput/answered") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "user-input.resolved", + payload: { + answers: toCanonicalUserInputAnswers( + asObject(event.payload)?.answers as ProviderUserInputAnswers | undefined, + ), + }, + }, + ]; + } + + if (event.method === "codex/event/task_started") { + const msg = codexEventMessage(payload); + const taskId = asString(payload?.id) ?? asString(msg?.turn_id); + if (!taskId) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "task.started", + payload: { + taskId: asRuntimeTaskId(taskId), + ...(asString(msg?.collaboration_mode_kind) + ? { taskType: asString(msg?.collaboration_mode_kind) } + : {}), + }, + }, + ]; + } + + if (event.method === "codex/event/task_complete") { + const msg = codexEventMessage(payload); + const taskId = asString(payload?.id) ?? asString(msg?.turn_id); + if (!taskId) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "task.completed", + payload: { + taskId: asRuntimeTaskId(taskId), + status: "completed", + ...(asString(msg?.last_agent_message) + ? { summary: asString(msg?.last_agent_message) } + : {}), + }, + }, + ]; + } + + if (event.method === "codex/event/agent_reasoning") { + const msg = codexEventMessage(payload); + const taskId = asString(payload?.id); + const description = asString(msg?.text); + if (!taskId || !description) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "task.progress", + payload: { + taskId: asRuntimeTaskId(taskId), + description, + }, + }, + ]; + } + + if (event.method === "codex/event/reasoning_content_delta") { + const msg = codexEventMessage(payload); + const delta = asString(msg?.delta); + if (!delta) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "content.delta", + payload: { + streamKind: + asNumber(msg?.summary_index) !== undefined ? "reasoning_summary_text" : "reasoning_text", + delta, + ...(asNumber(msg?.summary_index) !== undefined + ? { summaryIndex: asNumber(msg?.summary_index) } + : {}), + }, + }, + ]; + } + if (event.method === "model/rerouted") { return [ { @@ -1034,6 +1275,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(input.interactionMode !== undefined + ? { interactionMode: input.interactionMode } + : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }; return manager.sendTurn(managerInput); @@ -1096,6 +1340,16 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), }); + const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.tryPromise({ + try: () => manager.respondToUserInput(threadId, requestId, answers), + catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), + }); + const stopSession: CodexAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { manager.stopSession(threadId); @@ -1163,6 +1417,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => readThread, rollbackThread, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 7d462744e77332d00faa01242c3c3e351c320650..6b5c9df31ae23aeb950b39d1a773de9d1431c9ab 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1490,6 +1490,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); }); + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Cursor does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); @@ -1539,6 +1552,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { readThread, rollbackThread, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index ed56c5106d3423a9c32d4c4e75683d531980557f..e44bd746cdfbde7b344ed4e037a6f41f95a95d00 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -19,6 +19,7 @@ const fakeCodexAdapter: CodexAdapterShape = { sendTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), stopSession: vi.fn(), listSessions: vi.fn(), hasSession: vi.fn(), @@ -35,6 +36,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = { sendTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), stopSession: vi.fn(), listSessions: vi.fn(), hasSession: vi.fn(), @@ -51,6 +53,7 @@ const fakeCursorAdapter: CursorAdapterShape = { sendTurn: vi.fn(), interruptTurn: vi.fn(), respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), stopSession: vi.fn(), listSessions: vi.fn(), hasSession: vi.fn(), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 81cf9d47f2ab26e9a5d31f02ceb74515c39e74f6..505ce88bf41ab00392a9735bfb70d8b205a2b19f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -116,6 +116,14 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { ): Effect.Effect => Effect.void, ); + const respondToUserInput = vi.fn( + ( + _threadId: ThreadId, + _requestId: string, + _answers: Record, + ): Effect.Effect => Effect.void, + ); + const stopSession = vi.fn( (threadId: ThreadId): Effect.Effect => Effect.sync(() => { @@ -172,6 +180,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, @@ -192,6 +201,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, @@ -395,12 +405,12 @@ it.effect( provider?: string; cwd?: string; resumeCursor?: unknown; - resumeThreadId?: string; + threadId?: string; }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project"); assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); - assert.equal(startPayload.resumeThreadId, "thread-1"); + assert.equal(startPayload.threadId, startedSession.threadId); } assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; @@ -446,6 +456,23 @@ routing.layer("ProviderServiceLive routing", (it) => { [session.threadId, asRequestId("req-1"), "accept"], ]); + yield* provider.respondToUserInput({ + threadId: session.threadId, + requestId: asRequestId("req-user-input-1"), + answers: { + sandbox_mode: "workspace-write", + }, + }); + assert.deepEqual(routing.codex.respondToUserInput.mock.calls, [ + [ + session.threadId, + asRequestId("req-user-input-1"), + { + sandbox_mode: "workspace-write", + }, + ], + ]); + yield* provider.rollbackConversation({ threadId: session.threadId, numTurns: 0, @@ -519,12 +546,12 @@ routing.layer("ProviderServiceLive routing", (it) => { provider?: string; cwd?: string; resumeCursor?: unknown; - resumeThreadId?: string; + threadId?: string; }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project"); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); - assert.equal(startPayload.resumeThreadId, "thread-2"); + assert.equal(startPayload.threadId, initial.threadId); } assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; @@ -561,12 +588,12 @@ routing.layer("ProviderServiceLive routing", (it) => { provider?: string; cwd?: string; resumeCursor?: unknown; - resumeThreadId?: string; + threadId?: string; }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project-send-turn"); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); - assert.equal(startPayload.resumeThreadId, "thread-4"); + assert.equal(startPayload.threadId, initial.threadId); } assert.equal(routing.codex.sendTurn.mock.calls.length, 1); }), @@ -629,7 +656,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(runtimePayload.cwd, process.cwd()); assert.equal(runtimePayload.model, null); - assert.equal(runtimePayload.activeTurnId, "turn-1"); + assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); } @@ -822,7 +849,9 @@ validation.layer("ProviderServiceLive validation", (it) => { const failure = yield* Effect.result( provider.startSession(asThreadId("thread-validation"), { + threadId: asThreadId("thread-validation"), provider: "invalid-provider", + runtimeMode: "full-access", } as never), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7025f8a2e1a050e6d563c36851b6dcf08b192ca9..3681d5b8fe2fd786c138fda38a9dcd56c84bd1a3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -14,6 +14,7 @@ import { ThreadId, ProviderInterruptTurnInput, ProviderRespondToRequestInput, + ProviderRespondToUserInputInput, ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, @@ -333,6 +334,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); }); + const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) => + Effect.gen(function* () { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.respondToUserInput", + schema: ProviderRespondToUserInputInput, + payload: rawInput, + }); + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.respondToUserInput", + allowRecovery: true, + }); + yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); + }); + const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => Effect.gen(function* () { const input = yield* decodeInputOrValidationError({ @@ -442,6 +458,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, getCapabilities, diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 631621e3e7d57dfbc4791e918dd29740ea559a45..67755b538340416834bd2df3028d99a7e62c5a84 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -11,6 +11,7 @@ import type { ApprovalRequestId, ProviderApprovalDecision, ProviderKind, + ProviderUserInputAnswers, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, @@ -79,6 +80,15 @@ export interface ProviderAdapterShape { decision: ProviderApprovalDecision, ) => Effect.Effect; + /** + * Respond to a structured user-input request. + */ + readonly respondToUserInput: ( + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + ) => Effect.Effect; + /** * Stop one provider session. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 048f9b41a6f8885533ad7d727a38b5759d1b92f4..600541e895a5bd34efceb02b9a553207ed9e6f7d 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -15,6 +15,7 @@ import type { ProviderInterruptTurnInput, ProviderKind, ProviderRespondToRequestInput, + ProviderRespondToUserInputInput, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, @@ -62,6 +63,13 @@ export interface ProviderServiceShape { input: ProviderRespondToRequestInput, ) => Effect.Effect; + /** + * Respond to a provider structured user-input request. + */ + readonly respondToUserInput: ( + input: ProviderRespondToUserInputInput, + ) => Effect.Effect; + /** * Stop a provider session. */ diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f10edc474ad99a336ce57eb39751c54ee93e0827..50e2561c59ccbb93402b39da2872dfc7b9f115b7 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1126,6 +1126,8 @@ describe("WebSocket Server", () => { projectId: "project-diff", title: "Diff Thread", model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", branch: null, worktreePath: null, createdAt, @@ -1164,6 +1166,7 @@ describe("WebSocket Server", () => { }), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), @@ -1202,6 +1205,8 @@ describe("WebSocket Server", () => { projectId: "project-1", title: "Thread 1", model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", branch: null, worktreePath: null, createdAt, @@ -1220,6 +1225,7 @@ describe("WebSocket Server", () => { }, assistantDeliveryMode: "streaming", runtimeMode: "approval-required", + interactionMode: "default", createdAt, }); expect(startTurnResponse.error).toBeUndefined(); @@ -1519,6 +1525,54 @@ describe("WebSocket Server", () => { }); }); + it("supports projects.writeFile within the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-write-file-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n\n- step 1\n", + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + relativePath: "plans/effect-rpc.md", + }); + expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe( + "# Plan\n\n- step 1\n", + ); + }); + + it("rejects projects.writeFile paths outside the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-write-file-reject-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "../escape.md", + contents: "# no\n", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Workspace file path must stay within the project root."); + expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); + }); + it("routes git core methods over websocket", async () => { const listBranches = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 971bbddf34711fd2e3023e82e024dcd2e63d7363..cd5e842732980431f964b3e0662fbfa7136891e2 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,6 +12,7 @@ import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, type ClientOrchestrationCommand, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, @@ -146,6 +147,48 @@ function websocketRawToString(raw: unknown): string | null { return null; } +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function resolveWorkspaceWritePath(params: { + workspaceRoot: string; + relativePath: string; + path: Path.Path; +}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { + const normalizedInputPath = params.relativePath.trim(); + if (params.path.isAbsolute(normalizedInputPath)) { + return Effect.fail( + new RouteRequestError({ + message: "Workspace file path must be relative to the project root.", + }), + ); + } + + const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath( + params.path.relative(params.workspaceRoot, absolutePath), + ); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + params.path.isAbsolute(relativeToRoot) + ) { + return Effect.fail( + new RouteRequestError({ + message: "Workspace file path must stay within the project root.", + }), + ); + } + + return Effect.succeed({ + absolutePath, + relativePath: relativeToRoot, + }); +} + function stripRequestTag(body: T) { return Struct.omit(body, ["_tag"]); } @@ -595,6 +638,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< projectId: bootstrapProjectId, title: "New thread", model: bootstrapProjectDefaultModel, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -684,6 +728,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } + case WS_METHODS.projectsWriteFile: { + const body = stripRequestTag(request.body); + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: body.cwd, + relativePath: body.relativePath, + path, + }); + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to prepare workspace path: ${String(cause)}`, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to write workspace file: ${String(cause)}`, + }), + ), + ); + return { relativePath: target.relativePath }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 80eede8fce269a3c9f1dbc50e02c807b54c265c1..d6400508c5b19ae576754c9abeae16d90ab83ed9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -41,6 +41,7 @@ import { useRef, useState, useTransition, + useId, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -57,17 +58,22 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { + type ComposerSlashCommand, type ComposerTrigger, type ComposerTriggerKind, detectComposerTrigger, expandCollapsedComposerCursor, + parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; import { derivePendingApprovals, + derivePendingUserInputs, derivePhase, deriveTimelineEntries, + deriveActivePlanState, type PendingApproval, + type PendingUserInput, PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, @@ -76,9 +82,24 @@ import { formatTimestamp, } from "../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; +import { + buildPendingUserInputAnswers, + derivePendingUserInputProgress, + setPendingUserInputCustomAnswer, + type PendingUserInputDraftAnswer, +} from "../pendingUserInput"; import { useStore } from "../store"; +import { + buildPlanImplementationThreadTitle, + buildPlanImplementationPrompt, + buildProposedPlanMarkdownFilename, + findLatestProposedPlanMessage, + parseProposedPlanMessage, + proposedPlanTitle, +} from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { + DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, @@ -114,6 +135,7 @@ import { FileIcon, FolderIcon, DiffIcon, + EllipsisIcon, FolderClosedIcon, InfoIcon, LockIcon, @@ -124,13 +146,8 @@ import { CheckIcon, } from "lucide-react"; import { Button } from "./ui/button"; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "./ui/select"; +import { Input } from "./ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; import { @@ -161,6 +178,16 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Command, CommandItem, CommandList } from "./ui/command"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -172,7 +199,7 @@ import { } from "~/projectScripts"; import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; -import { newCommandId, newMessageId } from "~/lib/utils"; +import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { getAppModelOptions, useAppSettings } from "../appSettings"; import { @@ -206,6 +233,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -236,6 +264,22 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } +function normalizePlanMarkdownForExport(planMarkdown: string): string { + return `${planMarkdown.trimEnd()}\n`; +} + +function downloadTextFile(filename: string, contents: string): void { + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => { + URL.revokeObjectURL(url); + }, 0); +} + interface ExpandedImageItem { src: string; name: string; @@ -279,6 +323,7 @@ function buildLocalDraftThread( title: "New thread", model: fallbackModel, runtimeMode: draftThread.runtimeMode, + interactionMode: draftThread.interactionMode, session: null, messages: [], error, @@ -336,6 +381,7 @@ type ComposerCommandItem = | { id: string; type: "slash-command"; + command: ComposerSlashCommand; label: string; description: string; } @@ -513,6 +559,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); @@ -560,6 +607,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); + const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< + ApprovalRequestId[] + >([]); + const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< + Record> + >({}); + const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = + useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); @@ -652,8 +707,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode); + const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode); const [isRuntimeModePending, startRuntimeModeTransition] = useTransition(); + const [isInteractionModePending, startInteractionModeTransition] = useTransition(); const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const diffSearch = useMemo( @@ -688,7 +746,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedProviderByThreadId = composerDraft.provider; const hasThreadStarted = Boolean( activeThread && - (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null), + (activeThread.latestTurn !== null || + activeThread.messages.length > 0 || + activeThread.session !== null), ); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) @@ -700,7 +760,10 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, + activeThread?.model ?? + activeProject?.model ?? + getDefaultModel(selectedProvider) ?? + DEFAULT_MODEL, ); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; @@ -716,7 +779,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider); if (normalizedDraftModel) { - const normalizedMatch = providerOptions.find((option) => option.slug === normalizedDraftModel); + const normalizedMatch = providerOptions.find( + (option) => option.slug === normalizedDraftModel, + ); if (normalizedMatch) { return normalizedMatch.slug as ModelSlug; } @@ -732,19 +797,23 @@ export default function ChatView({ threadId }: ChatViewProps) { [selectedModel, selectedProvider], ); const selectedCursorModelCapabilities = useMemo( - () => - selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null, + () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null), [selectedCursorModel], ); const hasSelectedCursorTraits = Boolean( selectedCursorModelCapabilities && - (selectedCursorModelCapabilities.supportsReasoning || - selectedCursorModelCapabilities.supportsFast || - selectedCursorModelCapabilities.supportsThinking), + (selectedCursorModelCapabilities.supportsReasoning || + selectedCursorModelCapabilities.supportsFast || + selectedCursorModelCapabilities.supportsThinking), ); const selectedModelForPicker = - selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; - const modelOptionsByProvider = useMemo(() => getCustomModelOptionsByProvider(settings), [settings]); + selectedProvider === "cursor" && selectedCursorModel + ? selectedCursorModel.family + : selectedModel; + const modelOptionsByProvider = useMemo( + () => getCustomModelOptionsByProvider(settings), + [settings], + ); const selectedModelForPickerWithCustomFallback = useMemo(() => { if (selectedProvider !== "cursor") { const currentOptions = modelOptionsByProvider[selectedProvider]; @@ -794,6 +863,80 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingApprovals(threadActivities), [threadActivities], ); + const pendingUserInputs = useMemo( + () => derivePendingUserInputs(threadActivities), + [threadActivities], + ); + const activePendingUserInput = pendingUserInputs[0] ?? null; + const activePendingDraftAnswers = useMemo( + () => + activePendingUserInput + ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? + EMPTY_PENDING_USER_INPUT_ANSWERS) + : EMPTY_PENDING_USER_INPUT_ANSWERS, + [activePendingUserInput, pendingUserInputAnswersByRequestId], + ); + const activePendingQuestionIndex = activePendingUserInput + ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + : 0; + const activePendingProgress = useMemo( + () => + activePendingUserInput + ? derivePendingUserInputProgress( + activePendingUserInput.questions, + activePendingDraftAnswers, + activePendingQuestionIndex, + ) + : null, + [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], + ); + const activePendingResolvedAnswers = useMemo( + () => + activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + : null, + [activePendingDraftAnswers, activePendingUserInput], + ); + const activePendingIsResponding = activePendingUserInput + ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) + : false; + const activeProposedPlan = useMemo(() => { + if (!latestTurnSettled) { + return null; + } + return findLatestProposedPlanMessage( + activeThread?.messages ?? [], + activeLatestTurn?.assistantMessageId ?? null, + ); + }, [activeLatestTurn?.assistantMessageId, activeThread?.messages, latestTurnSettled]); + const activePlan = useMemo( + () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), + [activeLatestTurn?.turnId, threadActivities], + ); + const showPlanFollowUpPrompt = + pendingUserInputs.length === 0 && + optimisticInteractionMode === "plan" && + latestTurnSettled && + activeProposedPlan !== null; + const hasComposerHeader = + pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); + useEffect(() => { + if (!activePendingProgress) { + return; + } + promptRef.current = activePendingProgress.customAnswer; + setComposerCursor(activePendingProgress.customAnswer.length); + setComposerTrigger( + detectComposerTrigger( + activePendingProgress.customAnswer, + expandCollapsedComposerCursor( + activePendingProgress.customAnswer, + activePendingProgress.customAnswer.length, + ), + ), + ); + setComposerHighlightedItemId(null); + }, [activePendingProgress, activePendingUserInput?.requestId]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1038,31 +1181,54 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "slash-command") { - if (!"model".includes(composerTrigger.query.toLowerCase())) { - return []; - } - return [ + const slashCommandItems = [ { id: "slash:model", type: "slash-command", + command: "model", label: "/model", description: "Switch response model for this thread", }, - ]; - } - - return searchableModelOptions.filter(({ searchSlug, searchName, searchProvider }) => { + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal chat mode", + }, + ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query); - }).map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); + if (!query) { + return [...slashCommandItems]; + } + return slashCommandItems.filter( + (item) => item.command.includes(query) || item.label.slice(1).includes(query), + ); + } + + return searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return ( + searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); }, [composerTrigger, searchableModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( @@ -1444,6 +1610,41 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }; + const handleInteractionModeChange = useCallback( + async (mode: "default" | "plan") => { + if (mode === interactionMode) return; + if (isLocalDraftThread) { + setDraftThreadContext(threadId, { interactionMode: mode }); + scheduleComposerFocus(); + return; + } + const api = readNativeApi(); + if (!api || !isServerThread) return; + scheduleComposerFocus(); + startInteractionModeTransition(() => { + setOptimisticInteractionMode(mode); + void api.orchestration + .dispatchCommand({ + type: "thread.interaction-mode.set", + commandId: newCommandId(), + threadId, + interactionMode: mode, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + }); + }, + [ + interactionMode, + isLocalDraftThread, + isServerThread, + scheduleComposerFocus, + setDraftThreadContext, + setOptimisticInteractionMode, + threadId, + ], + ); + useEffect(() => { try { if (Object.keys(lastInvokedScriptByProjectId).length === 0) { @@ -2070,7 +2271,39 @@ export default function ChatView({ threadId }: ChatViewProps) { e?.preventDefault(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (activePendingProgress) { + onAdvanceActivePendingUserInput(); + return; + } const trimmed = prompt.trim(); + if (showPlanFollowUpPrompt && activeProposedPlan) { + const followUpText = + trimmed.length > 0 + ? trimmed + : buildPlanImplementationPrompt(activeProposedPlan.plan.planMarkdown); + const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + await onSubmitPlanFollowUp({ + text: followUpText, + interactionMode: nextInteractionMode, + }); + return; + } + const standaloneSlashCommand = + composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + if (standaloneSlashCommand) { + await handleInteractionModeChange(standaloneSlashCommand); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + return; + } if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; @@ -2183,7 +2416,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncateTitle(titleSeed); - let threadCreateModel: ModelSlug = selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL; + let threadCreateModel: ModelSlug = + selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2194,6 +2428,7 @@ export default function ChatView({ threadId }: ChatViewProps) { title, model: threadCreateModel, runtimeMode, + interactionMode, branch: nextThreadBranch, worktreePath: nextThreadWorktreePath, createdAt: activeThread.createdAt, @@ -2254,6 +2489,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, + interactionMode, createdAt: messageCreatedAt, }); turnStartSucceeded = true; @@ -2337,6 +2573,345 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThreadId, setStoreThreadError], ); + const onRespondToUserInput = useCallback( + async (requestId: ApprovalRequestId, answers: Record) => { + const api = readNativeApi(); + if (!api || !activeThreadId) return; + + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + await api.orchestration + .dispatchCommand({ + type: "thread.user-input.respond", + commandId: newCommandId(), + threadId: activeThreadId, + requestId, + answers, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + setStoreThreadError( + activeThreadId, + err instanceof Error ? err.message : "Failed to submit user input.", + ); + }); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + }, + [activeThreadId, setStoreThreadError], + ); + + const setActivePendingUserInputQuestionIndex = useCallback( + (nextQuestionIndex: number) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: nextQuestionIndex, + })); + }, + [activePendingUserInput], + ); + + const onSelectActivePendingUserInputOption = useCallback( + (questionId: string, optionLabel: string) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: { + selectedOptionLabel: optionLabel, + customAnswer: "", + }, + }, + })); + promptRef.current = ""; + setComposerCursor(0); + setComposerTrigger(null); + }, + [activePendingUserInput], + ); + + const onChangeActivePendingUserInputCustomAnswer = useCallback( + (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + if (!activePendingUserInput) { + return; + } + promptRef.current = value; + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[questionId], + value, + ), + }, + })); + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention + ? null + : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + ); + }, + [activePendingUserInput], + ); + + const onAdvanceActivePendingUserInput = useCallback(() => { + if (!activePendingUserInput || !activePendingProgress) { + return; + } + if (activePendingProgress.isLastQuestion) { + if (activePendingResolvedAnswers) { + void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + } + return; + } + setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + }, [ + activePendingProgress, + activePendingResolvedAnswers, + activePendingUserInput, + onRespondToUserInput, + setActivePendingUserInputQuestionIndex, + ]); + + const onPreviousActivePendingUserInputQuestion = useCallback(() => { + if (!activePendingProgress) { + return; + } + setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); + }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + + const onSubmitPlanFollowUp = useCallback( + async ({ + text, + interactionMode: nextInteractionMode, + }: { + text: string; + interactionMode: "default" | "plan"; + }) => { + const api = readNativeApi(); + if ( + !api || + !activeThread || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + + const trimmed = text.trim(); + if (!trimmed) { + return; + } + + const threadIdForSend = activeThread.id; + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); + + sendInFlightRef.current = true; + setSendPhase("sending-turn"); + setThreadError(threadIdForSend, null); + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: trimmed, + createdAt: messageCreatedAt, + streaming: false, + }, + ]); + shouldAutoScrollRef.current = true; + forceStickToBottom(); + + try { + if (nextInteractionMode !== interactionMode) { + setOptimisticInteractionMode(nextInteractionMode); + await api.orchestration.dispatchCommand({ + type: "thread.interaction-mode.set", + commandId: newCommandId(), + threadId: threadIdForSend, + interactionMode: nextInteractionMode, + createdAt: messageCreatedAt, + }); + } + + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: trimmed, + attachments: [], + }, + provider: selectedProvider, + model: selectedModel || undefined, + ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + runtimeMode, + interactionMode: nextInteractionMode, + createdAt: messageCreatedAt, + }); + sendInFlightRef.current = false; + setSendPhase("idle"); + } catch (err) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + if (nextInteractionMode !== interactionMode) { + setOptimisticInteractionMode(interactionMode); + } + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send plan follow-up.", + ); + sendInFlightRef.current = false; + setSendPhase("idle"); + } + }, + [ + activeThread, + forceStickToBottom, + interactionMode, + isConnecting, + isSendBusy, + isServerThread, + runtimeMode, + selectedEffort, + selectedModel, + selectedProvider, + setOptimisticInteractionMode, + setThreadError, + settings.enableAssistantStreaming, + supportsReasoningEffort, + ], + ); + + const onImplementPlanInNewThread = useCallback(async () => { + const api = readNativeApi(); + if ( + !api || + !activeThread || + !activeProject || + !activeProposedPlan || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + + const createdAt = new Date().toISOString(); + const nextThreadId = newThreadId(); + const planMarkdown = activeProposedPlan.plan.planMarkdown; + const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadModel: ModelSlug = + selectedModel || + (activeThread.model as ModelSlug) || + (activeProject.model as ModelSlug) || + DEFAULT_MODEL; + + sendInFlightRef.current = true; + setSendPhase("sending-turn"); + const finish = () => { + sendInFlightRef.current = false; + setSendPhase("idle"); + }; + + await api.orchestration + .dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: nextThreadId, + projectId: activeProject.id, + title: nextThreadTitle, + model: nextThreadModel, + runtimeMode, + interactionMode: "default", + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt, + }) + .then(() => + api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: nextThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: implementationPrompt, + attachments: [], + }, + provider: selectedProvider, + model: selectedModel || undefined, + ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + runtimeMode, + interactionMode: "default", + createdAt, + }), + ) + .then(() => api.orchestration.getSnapshot()) + .then((snapshot) => { + syncServerReadModel(snapshot); + return navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); + }) + .catch(async (err) => { + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: nextThreadId, + }) + .catch(() => undefined); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + toastManager.add({ + type: "error", + title: "Could not start implementation thread", + description: + err instanceof Error ? err.message : "An error occurred while creating the new thread.", + }); + }) + .then(finish, finish); + }, [ + activeProject, + activeProposedPlan, + activeThread, + isConnecting, + isSendBusy, + isServerThread, + navigate, + runtimeMode, + selectedEffort, + selectedModel, + selectedProvider, + settings.enableAssistantStreaming, + supportsReasoningEffort, + syncServerReadModel, + ]); + const onProviderModelSelect = useCallback( (provider: ProviderKind, model: ModelSlug) => { if (!activeThread) return; @@ -2452,7 +3027,21 @@ export default function ChatView({ threadId }: ChatViewProps) { } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); promptRef.current = next.text; - setPrompt(next.text); + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [activePendingQuestion.id]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], + next.text, + ), + }, + })); + } else { + setPrompt(next.text); + } setComposerCursor(next.cursor); setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); window.requestAnimationFrame(() => { @@ -2460,7 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return true; }, - [setPrompt], + [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], ); const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { @@ -2506,7 +3095,17 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } if (item.type === "slash-command") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { + if (item.command === "model") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { + expectedText: expectedToken, + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: expectedToken, }); if (applied) { @@ -2522,7 +3121,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); } }, - [applyPromptReplacement, onProviderModelSelect, resolveActiveComposerTrigger], + [ + applyPromptReplacement, + handleInteractionModeChange, + onProviderModelSelect, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); @@ -2553,6 +3157,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const onPromptChange = useCallback( (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + if (activePendingProgress?.activeQuestion && activePendingUserInput) { + onChangeActivePendingUserInputCustomAnswer( + activePendingProgress.activeQuestion.id, + nextPrompt, + nextCursor, + cursorAdjacentToMention, + ); + return; + } promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); @@ -2565,7 +3178,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [setPrompt], + [ + activePendingProgress?.activeQuestion, + activePendingUserInput, + onChangeActivePendingUserInputCustomAnswer, + setPrompt, + ], ); const onComposerCommandKey = ( @@ -2698,6 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { respondingRequestIds={respondingRequestIds} onRespondToApproval={onRespondToApproval} /> + {/* Messages */}
@@ -2754,8 +3374,32 @@ export default function ChatView({ threadId }: ChatViewProps) { onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} > + {pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} + {/* Textarea area */} -
+
{composerMenuOpen && (
)} - {composerImages.length > 0 && ( + {pendingUserInputs.length === 0 && composerImages.length > 0 && (
{composerImages.map((image) => (
@@ -2890,39 +3538,44 @@ export default function ChatView({ threadId }: ChatViewProps) { )} - {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && ( - <> - {cursorModelSelectionLockedReason ? ( - - - - - } + {selectedCursorModel && + selectedCursorModelCapabilities && + hasSelectedCursorTraits && ( + <> + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + - - {cursorModelSelectionLockedReason} - - - ) : ( - - )} - - )} + )} + + )} ) : supportsReasoningEffort ? ( <> @@ -2943,6 +3596,33 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Divider */} + {/* Interaction mode toggle */} + + + {/* Divider */} + + {/* Runtime mode toggle */} + ) : null} + +
+ ) : phase === "running" ? ( - ) : ( - ) : ( - - )} - - )} +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
+ ) + ) : ( + + ) + ) : null}
@@ -3326,7 +4084,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ ? "Command approval requested" : approval.requestKind === "file-read" ? "File-read approval requested" - : "File-change approval requested"} + : "File-change approval requested"} ; +} + +const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) { + if (!activePlan) return null; + + return ( +
+
+
+ Plan + + Updated {formatTimestamp(activePlan.createdAt)} + +
+ {activePlan.explanation ? ( +

{activePlan.explanation}

+ ) : null} +
+ {activePlan.steps.map((step) => ( +
+ + {step.status === "inProgress" + ? "In progress" + : step.status === "completed" + ? "Done" + : "Pending"} + +
{step.step}
+
+ ))} +
+
+
+ ); +}); + +interface PendingUserInputPanelProps { + pendingUserInputs: PendingUserInput[]; + respondingRequestIds: ApprovalRequestId[]; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; +} + +const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ + pendingUserInputs, + respondingRequestIds, + answers, + questionIndex, + onSelectOption, +}: PendingUserInputPanelProps) { + if (pendingUserInputs.length === 0) return null; + const activePrompt = pendingUserInputs[0]; + if (!activePrompt) return null; + + return ( + + ); +}); + +const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ + prompt, + pendingPromptCount, + isResponding, + answers, + questionIndex, + onSelectOption, +}: { + prompt: PendingUserInput; + pendingPromptCount: number; + isResponding: boolean; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; +}) { + const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); + const activeQuestion = progress.activeQuestion; + + if (!activeQuestion) { + return null; + } + + return ( +
+
+ + {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} + +
{activeQuestion.question}
+
+
+ {activeQuestion.options.map((option) => { + const isSelected = progress.selectedOptionLabel === option.label; + return ( + + ); + })} +
+
+ ); +}); + +const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ + planTitle, +}: { + planTitle: string | null; +}) { + return ( +
+
+ Plan ready + {planTitle ? ( + {planTitle} + ) : null} +
+
+ Leave the composer empty to implement here, use the menu to implement in a new thread, or + type feedback to refine it. +
+
+ ); +}); + const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -3537,6 +4448,191 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { return (
{treeNodes.map((node) => renderTreeNode(node, 0))} +const ProposedPlanMessage = memo(function ProposedPlanMessage({ + text, + cwd, + workspaceRoot, + isStreaming, +}: { + text: string; + cwd: string | undefined; + workspaceRoot: string | undefined; + isStreaming: boolean; +}) { + const proposedPlan = parseProposedPlanMessage(text); + const [expanded, setExpanded] = useState(false); + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [savePath, setSavePath] = useState(""); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const savePathInputId = useId(); + + if (!proposedPlan) { + return ; + } + + const title = proposedPlanTitle(proposedPlan.planMarkdown) ?? "Proposed plan"; + const lineCount = proposedPlan.planMarkdown.split("\n").length; + const canCollapse = proposedPlan.planMarkdown.length > 900 || lineCount > 20; + const downloadFilename = buildProposedPlanMarkdownFilename(proposedPlan.planMarkdown); + const saveContents = normalizePlanMarkdownForExport(proposedPlan.planMarkdown); + + const handleDownload = () => { + downloadTextFile(downloadFilename, saveContents); + }; + + const openSaveDialog = () => { + if (!workspaceRoot) { + toastManager.add({ + type: "error", + title: "Workspace path is unavailable", + description: "This thread does not have a workspace path to save into.", + }); + return; + } + setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); + setIsSaveDialogOpen(true); + }; + + const handleSaveToWorkspace = () => { + const api = readNativeApi(); + const relativePath = savePath.trim(); + if (!api || !workspaceRoot) { + return; + } + if (!relativePath) { + toastManager.add({ + type: "warning", + title: "Enter a workspace path", + }); + return; + } + + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath, + contents: saveContents, + }) + .then((result) => { + setIsSaveDialogOpen(false); + toastManager.add({ + type: "success", + title: "Plan saved to workspace", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred while saving.", + }); + }) + .then( + () => { + setIsSavingToWorkspace(false); + }, + () => { + setIsSavingToWorkspace(false); + }, + ); + }; + + return ( +
+ {proposedPlan.beforeText ? ( + + ) : null} + +
+
+
+ Plan +

{title}

+
+ + } + > + + + Download as markdown + + Save to workspace + + + +
+
+
+ + {canCollapse && !expanded ? ( +
+ ) : null} +
+ {canCollapse ? ( +
+ +
+ ) : null} +
+
+ + { + if (!isSavingToWorkspace) { + setIsSaveDialogOpen(open); + } + }} + > + + + Save plan to workspace + + Enter a path relative to {workspaceRoot ?? "the workspace"}. + + + + + + + + + + + + + {proposedPlan.afterText ? ( + + ) : null}
); }); @@ -3561,6 +4657,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + workspaceRoot: string | undefined; } type TimelineEntry = ReturnType[number]; @@ -3602,6 +4699,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + workspaceRoot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -3946,9 +5044,10 @@ const MessagesTimeline = memo(function MessagesTimeline({
)}
- {(() => { @@ -4201,7 +5300,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { return ( - @@ -4354,7 +5456,9 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { )} {props.capabilities.supportsReasoning && - (props.capabilities.supportsFast || props.capabilities.supportsThinking) && } + (props.capabilities.supportsFast || props.capabilities.supportsThinking) && ( + + )} {props.capabilities.supportsFast && (
Fast Mode
diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b9990d7fdfd3b8fd977ab761bbb39604e6ca29d1..7e6805c96d5483d987a4862023bf12a76b9c2835 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -4,6 +4,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToMention, + parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -43,6 +44,18 @@ describe("detectComposerTrigger", () => { rangeEnd: text.length, }); }); + + it("detects non-model slash commands while typing", () => { + const text = "/pl"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "pl", + rangeStart: 0, + rangeEnd: text.length, + }); + }); }); describe("replaceTextRange", () => { @@ -111,3 +124,17 @@ describe("isCollapsedCursorAdjacentToMention", () => { expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); }); }); + +describe("parseStandaloneComposerSlashCommand", () => { + it("parses standalone /plan command", () => { + expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan"); + }); + + it("parses standalone /default command", () => { + expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); + }); + + it("ignores slash commands with extra message text", () => { + expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 843a5255c3282a8c68487afc2d41221c562a0a88..f2e367bcf037e48db6f248b14401980ed57481ea 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,6 +1,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; +export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -9,6 +10,8 @@ export interface ComposerTrigger { rangeEnd: number; } +const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; + function clampCursor(text: string, cursor: number): number { if (!Number.isFinite(cursor)) return text.length; return Math.max(0, Math.min(text.length, Math.floor(cursor))); @@ -121,7 +124,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if ("model".startsWith(commandQuery.toLowerCase())) { + if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { return { kind: "slash-command", query: commandQuery, @@ -157,6 +160,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +export function parseStandaloneComposerSlashCommand(text: string): Exclude< + ComposerSlashCommand, + "model" +> | null { + const match = /^\/(plan|default)\s*$/i.exec(text.trim()); + if (!match) { + return null; + } + return match[1]?.toLowerCase() === "plan" ? "plan" : "default"; +} + export function replaceTextRange( text: string, rangeStart: number, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index c597b648a6c53eff3272c593f2555f52225f958e..60284dd253561f90e69cd17fdeb19ebf177e7a80 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -6,9 +6,14 @@ import { normalizeModelSlug, type CodexReasoningEffort, type ProviderKind, + type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatImageAttachment, +} from "./types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -40,6 +45,7 @@ interface PersistedDraftThreadState { projectId: ProjectId; createdAt: string; runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; @@ -65,6 +71,7 @@ export interface DraftThreadState { projectId: ProjectId; createdAt: string; runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; @@ -89,6 +96,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; }, ) => void; setDraftThreadContext: ( @@ -100,6 +108,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; @@ -270,6 +279,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer candidateDraftThread.runtimeMode === "full-access" ? candidateDraftThread.runtimeMode : DEFAULT_RUNTIME_MODE, + interactionMode: + candidateDraftThread.interactionMode === "plan" || + candidateDraftThread.interactionMode === "default" + ? candidateDraftThread.interactionMode + : DEFAULT_INTERACTION_MODE, branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), @@ -297,6 +311,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer projectId: projectId as ProjectId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, branch: null, worktreePath: null, envMode: "local", @@ -500,6 +515,10 @@ export const useComposerDraftStore = create()( projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + options?.interactionMode ?? + existingThread?.interactionMode ?? + DEFAULT_INTERACTION_MODE, branch: options?.branch === undefined ? (existingThread?.branch ?? null) @@ -515,6 +534,7 @@ export const useComposerDraftStore = create()( existingThread.projectId === nextDraftThread.projectId && existingThread.createdAt === nextDraftThread.createdAt && existingThread.runtimeMode === nextDraftThread.runtimeMode && + existingThread.interactionMode === nextDraftThread.interactionMode && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && existingThread.envMode === nextDraftThread.envMode; @@ -570,6 +590,7 @@ export const useComposerDraftStore = create()( ? existing.createdAt : options.createdAt || existing.createdAt, runtimeMode: options.runtimeMode ?? existing.runtimeMode, + interactionMode: options.interactionMode ?? existing.interactionMode, branch: options.branch === undefined ? existing.branch : (options.branch ?? null), worktreePath: nextWorktreePath, envMode: @@ -580,6 +601,7 @@ export const useComposerDraftStore = create()( nextDraftThread.projectId === existing.projectId && nextDraftThread.createdAt === existing.createdAt && nextDraftThread.runtimeMode === existing.runtimeMode && + nextDraftThread.interactionMode === existing.interactionMode && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode; diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..153b3153563e98b0566bd74ab9602f216de405f8 --- /dev/null +++ b/apps/web/src/pendingUserInput.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; + +import { + buildPendingUserInputAnswers, + countAnsweredPendingUserInputQuestions, + derivePendingUserInputProgress, + findFirstUnansweredPendingUserInputQuestionIndex, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, +} from "./pendingUserInput"; + +describe("resolvePendingUserInputAnswer", () => { + it("prefers a custom answer over a selected option", () => { + expect( + resolvePendingUserInputAnswer({ + selectedOptionLabel: "Keep current envelope", + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("falls back to the selected option", () => { + expect( + resolvePendingUserInputAnswer({ + selectedOptionLabel: "Scaffold only", + }), + ).toBe("Scaffold only"); + }); + + it("clears the preset selection when a custom answer is entered", () => { + expect( + setPendingUserInputCustomAnswer( + { + selectedOptionLabel: "Preserve existing tags", + }, + "doesn't matter", + ), + ).toEqual({ + selectedOptionLabel: undefined, + customAnswer: "doesn't matter", + }); + }); +}); + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers( + [ + { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { + label: "Orchestration-first", + description: "Focus on orchestration first", + }, + ], + }, + { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [ + { + label: "Keep current envelope", + description: "Preserve current wire format", + }, + ], + }, + ], + { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + compat: { + customAnswer: "Keep the current envelope for one release window", + }, + }, + ), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); + + it("returns null when any question is unanswered", () => { + expect( + buildPendingUserInputAnswers( + [ + { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { + label: "Orchestration-first", + description: "Focus on orchestration first", + }, + ], + }, + ], + {}, + ), + ).toBeNull(); + }); +}); + +describe("pending user input question progress", () => { + const questions = [ + { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { + label: "Orchestration-first", + description: "Focus on orchestration first", + }, + ], + }, + { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [ + { + label: "Keep current envelope", + description: "Preserve current wire format", + }, + ], + }, + ] as const; + + it("counts only answered questions", () => { + expect( + countAnsweredPendingUserInputQuestions(questions, { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + }), + ).toBe(1); + }); + + it("finds the first unanswered question", () => { + expect( + findFirstUnansweredPendingUserInputQuestionIndex(questions, { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + }), + ).toBe(1); + }); + + it("returns the last question index when all answers are complete", () => { + expect( + findFirstUnansweredPendingUserInputQuestionIndex(questions, { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + compat: { + customAnswer: "Keep it for one release window", + }, + }), + ).toBe(1); + }); + + it("derives the active question and advancement state", () => { + expect( + derivePendingUserInputProgress( + questions, + { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + }, + 0, + ), + ).toMatchObject({ + questionIndex: 0, + activeQuestion: questions[0], + selectedOptionLabel: "Orchestration-first", + customAnswer: "", + resolvedAnswer: "Orchestration-first", + answeredQuestionCount: 1, + isLastQuestion: false, + isComplete: false, + canAdvance: true, + }); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd592bd62bf65edafda4ee625261d547b4cbebe2 --- /dev/null +++ b/apps/web/src/pendingUserInput.ts @@ -0,0 +1,122 @@ +import type { UserInputQuestion } from "@t3tools/contracts"; + +export interface PendingUserInputDraftAnswer { + selectedOptionLabel?: string; + customAnswer?: string; +} + +export interface PendingUserInputProgress { + questionIndex: number; + activeQuestion: UserInputQuestion | null; + activeDraft: PendingUserInputDraftAnswer | undefined; + selectedOptionLabel: string | undefined; + customAnswer: string; + resolvedAnswer: string | null; + usingCustomAnswer: boolean; + answeredQuestionCount: number; + isLastQuestion: boolean; + isComplete: boolean; + canAdvance: boolean; +} + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolvePendingUserInputAnswer( + draft: PendingUserInputDraftAnswer | undefined, +): string | null { + const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer) { + return customAnswer; + } + + return normalizeDraftAnswer(draft?.selectedOptionLabel); +} + +export function setPendingUserInputCustomAnswer( + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + const selectedOptionLabel = + customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + + return { + customAnswer, + ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + }; +} + +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + if (!answer) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} + +export function countAnsweredPendingUserInputQuestions( + questions: ReadonlyArray, + draftAnswers: Record, +): number { + return questions.reduce((count, question) => { + return resolvePendingUserInputAnswer(draftAnswers[question.id]) ? count + 1 : count; + }, 0); +} + +export function findFirstUnansweredPendingUserInputQuestionIndex( + questions: ReadonlyArray, + draftAnswers: Record, +): number { + const unansweredIndex = questions.findIndex( + (question) => !resolvePendingUserInputAnswer(draftAnswers[question.id]), + ); + + return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; +} + +export function derivePendingUserInputProgress( + questions: ReadonlyArray, + draftAnswers: Record, + questionIndex: number, +): PendingUserInputProgress { + const normalizedQuestionIndex = + questions.length === 0 + ? 0 + : Math.max(0, Math.min(questionIndex, questions.length - 1)); + const activeQuestion = questions[normalizedQuestionIndex] ?? null; + const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; + const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); + const customAnswer = activeDraft?.customAnswer ?? ""; + const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); + const isLastQuestion = + questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; + + return { + questionIndex: normalizedQuestionIndex, + activeQuestion, + activeDraft, + selectedOptionLabel: activeDraft?.selectedOptionLabel, + customAnswer, + resolvedAnswer, + usingCustomAnswer: customAnswer.trim().length > 0, + answeredQuestionCount, + isLastQuestion, + isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, + canAdvance: Boolean(resolvedAnswer), + }; +} diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bdf17b2f06d6949ba911c16c09ee37e1463ccdf --- /dev/null +++ b/apps/web/src/proposedPlan.test.ts @@ -0,0 +1,123 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + buildPlanImplementationThreadTitle, + buildPlanImplementationPrompt, + buildProposedPlanMarkdownFilename, + findLatestProposedPlanMessage, + parseProposedPlanMessage, + proposedPlanTitle, +} from "./proposedPlan"; + +describe("parseProposedPlanMessage", () => { + it("extracts the proposed plan block and surrounding assistant text", () => { + expect( + parseProposedPlanMessage( + "First I checked the repo.\n\n\n# Ship plan\n\n- step 1\n\n\nImplement this plan?", + ), + ).toEqual({ + beforeText: "First I checked the repo.", + planMarkdown: "# Ship plan\n\n- step 1", + afterText: "Implement this plan?", + }); + }); + + it("returns null when the assistant message has no plan block", () => { + expect(parseProposedPlanMessage("Plain assistant text")).toBeNull(); + }); +}); + +describe("findLatestProposedPlanMessage", () => { + it("prefers the latest turn assistant message id when it contains a proposed plan", () => { + const match = findLatestProposedPlanMessage( + [ + { + id: MessageId.makeUnsafe("assistant:older-plan"), + role: "assistant", + text: "\n# Older plan\n", + createdAt: "2026-03-05T00:00:00.000Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("assistant:latest-plan"), + role: "assistant", + text: "\n# Latest plan\n", + createdAt: "2026-03-05T00:01:00.000Z", + streaming: false, + }, + ], + "assistant:latest-plan", + ); + + expect(match?.message.id).toBe("assistant:latest-plan"); + expect(match?.plan.planMarkdown).toBe("# Latest plan"); + }); + + it("falls back to the latest assistant proposed plan when no latest-turn message id is available", () => { + const match = findLatestProposedPlanMessage( + [ + { + id: MessageId.makeUnsafe("assistant:plain"), + role: "assistant", + text: "No plan here", + createdAt: "2026-03-05T00:00:00.000Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("assistant:plan"), + role: "assistant", + text: "\n# Fallback plan\n", + createdAt: "2026-03-05T00:01:00.000Z", + streaming: false, + }, + ], + null, + ); + + expect(match?.message.id).toBe("assistant:plan"); + expect(match?.plan.planMarkdown).toBe("# Fallback plan"); + }); +}); + +describe("proposedPlanTitle", () => { + it("reads the first markdown heading as the plan title", () => { + expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC"); + }); + + it("returns null when the plan has no heading", () => { + expect(proposedPlanTitle("- step 1")).toBeNull(); + }); +}); + +describe("buildPlanImplementationPrompt", () => { + it("formats the plan exactly like the Codex follow-up handoff prompt", () => { + expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe( + "PLEASE IMPLEMENT THIS PLAN:\n## Ship it\n\n- step 1", + ); + }); +}); + +describe("buildPlanImplementationThreadTitle", () => { + it("uses the plan heading when building the implementation thread title", () => { + expect(buildPlanImplementationThreadTitle("# Integrate RPC\n\nBody")).toBe( + "Implement Integrate RPC", + ); + }); + + it("falls back when the plan has no markdown heading", () => { + expect(buildPlanImplementationThreadTitle("- step 1")).toBe("Implement plan"); + }); +}); + +describe("buildProposedPlanMarkdownFilename", () => { + it("derives a stable markdown filename from the plan heading", () => { + expect(buildProposedPlanMarkdownFilename("# Integrate Effect RPC Into Server App")).toBe( + "integrate-effect-rpc-into-server-app.md", + ); + }); + + it("falls back to a generic filename when the plan has no heading", () => { + expect(buildProposedPlanMarkdownFilename("- step 1")).toBe("plan.md"); + }); +}); diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts new file mode 100644 index 0000000000000000000000000000000000000000..b34ab7523431499419f005e4c8cec40f19c2baf8 --- /dev/null +++ b/apps/web/src/proposedPlan.ts @@ -0,0 +1,106 @@ +import type { MessageId } from "@t3tools/contracts"; + +import type { ChatMessage } from "./types"; + +export interface ProposedPlanBlock { + beforeText: string; + planMarkdown: string; + afterText: string; +} + +export interface ProposedPlanMessageMatch { + message: ChatMessage; + plan: ProposedPlanBlock; +} + +const PROPOSED_PLAN_REGEX = /\s*([\s\S]*?)\s*<\/proposed_plan>/; + +export function parseProposedPlanMessage(text: string): ProposedPlanBlock | null { + const match = PROPOSED_PLAN_REGEX.exec(text); + if (!match) { + return null; + } + + const fullMatch = match[0]; + const content = match[1]; + const start = match.index; + const end = start + fullMatch.length; + const planMarkdown = content?.trim(); + if (!planMarkdown) { + return null; + } + + return { + beforeText: text.slice(0, start).trim(), + planMarkdown, + afterText: text.slice(end).trim(), + }; +} + +export function proposedPlanTitle(planMarkdown: string): string | null { + const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : null; +} + +function sanitizePlanFileSegment(input: string): string { + const sanitized = input + .toLowerCase() + .replace(/[`'".,!?()[\]{}]+/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : "plan"; +} + +export function findLatestProposedPlanMessage( + messages: readonly ChatMessage[], + assistantMessageId: MessageId | string | null | undefined, +): ProposedPlanMessageMatch | null { + if (assistantMessageId) { + const matchingMessage = messages.find( + (message) => message.id === assistantMessageId && message.role === "assistant", + ); + if (matchingMessage) { + const parsedPlan = parseProposedPlanMessage(matchingMessage.text); + if (parsedPlan) { + return { + message: matchingMessage, + plan: parsedPlan, + }; + } + } + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message || message.role !== "assistant") { + continue; + } + const parsedPlan = parseProposedPlanMessage(message.text); + if (!parsedPlan) { + continue; + } + return { + message, + plan: parsedPlan, + }; + } + + return null; +} + +export function buildPlanImplementationPrompt(planMarkdown: string): string { + return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; +} + +export function buildPlanImplementationThreadTitle(planMarkdown: string): string { + const title = proposedPlanTitle(planMarkdown); + if (!title) { + return "Implement plan"; + } + return `Implement ${title}`; +} + +export function buildProposedPlanMarkdownFilename(planMarkdown: string): string { + const title = proposedPlanTitle(planMarkdown); + return `${sanitizePlanFileSegment(title ?? "plan")}.md`; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f78faf92f2e3bc7d424795f3bfe1165c1eec1963..bb8625942c64d8342873b050572130193f6ebc4a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2,8 +2,10 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont import { describe, expect, it } from "vitest"; import { + deriveActivePlanState, PROVIDER_OPTIONS, derivePendingApprovals, + derivePendingUserInputs, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, @@ -131,6 +133,130 @@ describe("derivePendingApprovals", () => { }); }); +describe("derivePendingUserInputs", () => { + it("tracks open structured prompts and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + makeActivity({ + id: "user-input-resolved", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "user-input.resolved", + summary: "User input submitted", + tone: "info", + payload: { + requestId: "req-user-input-2", + answers: { + sandbox_mode: "workspace-write", + }, + }, + }), + makeActivity({ + id: "user-input-open-2", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-2", + questions: [ + { + id: "approval", + header: "Approval", + question: "Continue?", + options: [ + { + label: "yes", + description: "Continue execution", + }, + ], + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([ + { + requestId: "req-user-input-1", + createdAt: "2026-02-23T00:00:01.000Z", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + ]); + }); +}); + +describe("deriveActivePlanState", () => { + it("returns the latest plan update for the active turn", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-old", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + explanation: "Initial plan", + plan: [{ step: "Inspect code", status: "pending" }], + }, + }), + makeActivity({ + id: "plan-latest", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + explanation: "Refined plan", + plan: [{ step: "Implement Codex user input", status: "inProgress" }], + }, + }), + ]; + + expect(deriveActivePlanState(activities, TurnId.makeUnsafe("turn-1"))).toEqual({ + createdAt: "2026-02-23T00:00:02.000Z", + turnId: "turn-1", + explanation: "Refined plan", + steps: [{ step: "Implement Codex user input", status: "inProgress" }], + }); + }); +}); + describe("deriveWorkLogEntries", () => { it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6b2849d5f00eabf9ad832e4805b309e87bf51a2c..61a3b11bcf4a255e38a491374582da69cf6c9b57 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -3,6 +3,7 @@ import { type OrchestrationLatestTurn, type OrchestrationThreadActivity, type ProviderKind, + type UserInputQuestion, type TurnId, } from "@t3tools/contracts"; @@ -33,6 +34,22 @@ export interface PendingApproval { detail?: string; } +export interface PendingUserInput { + requestId: ApprovalRequestId; + createdAt: string; + questions: ReadonlyArray; +} + +export interface ActivePlanState { + createdAt: string; + turnId: TurnId | null; + explanation?: string | null; + steps: Array<{ + step: string; + status: "pending" | "inProgress" | "completed"; + }>; +} + export type TimelineEntry = | { id: string; @@ -161,6 +178,155 @@ export function derivePendingApprovals( ); } +function parseUserInputQuestions( + payload: Record | null, +): ReadonlyArray | null { + const questions = payload?.questions; + if (!Array.isArray(questions)) { + return null; + } + const parsed = questions + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const question = entry as Record; + if ( + typeof question.id !== "string" || + typeof question.header !== "string" || + typeof question.question !== "string" || + !Array.isArray(question.options) + ) { + return null; + } + const options = question.options + .map((option) => { + if (!option || typeof option !== "object") return null; + const optionRecord = option as Record; + if ( + typeof optionRecord.label !== "string" || + typeof optionRecord.description !== "string" + ) { + return null; + } + return { + label: optionRecord.label, + description: optionRecord.description, + }; + }) + .filter((option): option is UserInputQuestion["options"][number] => option !== null); + if (options.length === 0) { + return null; + } + return { + id: question.id, + header: question.header, + question: question.question, + options, + }; + }) + .filter((question): question is UserInputQuestion => question !== null); + return parsed.length > 0 ? parsed : null; +} + +export function derivePendingUserInputs( + activities: ReadonlyArray, +): PendingUserInput[] { + const openByRequestId = new Map(); + const ordered = [...activities].toSorted(compareActivitiesByOrder); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = + payload && typeof payload.requestId === "string" + ? ApprovalRequestId.makeUnsafe(payload.requestId) + : null; + + if (activity.kind === "user-input.requested" && requestId) { + const questions = parseUserInputQuestions(payload); + if (!questions) { + continue; + } + openByRequestId.set(requestId, { + requestId, + createdAt: activity.createdAt, + questions, + }); + continue; + } + + if (activity.kind === "user-input.resolved" && requestId) { + openByRequestId.delete(requestId); + } + } + + return [...openByRequestId.values()].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +export function deriveActivePlanState( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const candidates = ordered.filter((activity) => { + if (activity.kind !== "turn.plan.updated") { + return false; + } + if (!latestTurnId) { + return true; + } + return activity.turnId === latestTurnId; + }); + const latest = candidates.at(-1); + if (!latest) { + return null; + } + const payload = + latest.payload && typeof latest.payload === "object" + ? (latest.payload as Record) + : null; + const rawPlan = payload?.plan; + if (!Array.isArray(rawPlan)) { + return null; + } + const steps = rawPlan + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const record = entry as Record; + if (typeof record.step !== "string") { + return null; + } + const status = + record.status === "completed" || record.status === "inProgress" + ? record.status + : "pending"; + return { + step: record.step, + status, + }; + }) + .filter( + ( + step, + ): step is { + step: string; + status: "pending" | "inProgress" | "completed"; + } => step !== null, + ); + if (steps.length === 0) { + return null; + } + return { + createdAt: latest.createdAt, + turnId: latest.turnId, + ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), + steps, + }; +} + export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2ed362b8eedde9bb99a7ac338f292f0fd48c7d9e..03e2874821d76d8c56f7635b0d8294ff9355280a 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too import { describe, expect, it } from "vitest"; import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; -import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { return { @@ -12,6 +12,7 @@ function makeThread(overrides: Partial = {}): Thread { title: "Thread", model: "gpt-5-codex", runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], turnDiffSummaries: [], @@ -49,6 +50,7 @@ function makeReadModelThread(overrides: Partial = {}): Thread { @@ -12,6 +12,7 @@ function makeThread(overrides: Partial = {}): Thread { title: "Thread", model: "gpt-5.3-codex", runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], turnDiffSummaries: [], diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index d2329a041d75a2ec0cc3e4b4fcf4e9b6d978c646..142174fb012c17697316cc312c649109a58f0031 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -6,6 +6,7 @@ import { ProjectId, ThreadId, WS_CHANNELS, + WS_METHODS, type ServerProviderStatus, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -330,6 +331,24 @@ describe("wsNativeApi", () => { }); }); + it("forwards workspace file writes to the websocket project method", async () => { + requestMock.mockResolvedValue({ relativePath: "plan.md" }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.projects.writeFile({ + cwd: "/tmp/project", + relativePath: "plan.md", + contents: "# Plan\n", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, { + cwd: "/tmp/project", + relativePath: "plan.md", + contents: "# Plan\n", + }); + }); + it("forwards full-thread diff requests to the orchestration websocket method", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f3bdfe5d0718d88f823dff0c2b1d61a5f67d900f..91e6a61107c3e42f0670200d3b0e96acda196660 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi { }, projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), + writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/docs/plan-mode-deep-dive.md b/docs/plan-mode-deep-dive.md index 7f417d2f7163f08c032ecca1b4c51957f5acfbd8..6088aa6c2a8d2e95e65202b07885b5797804c6a9 100644 --- a/docs/plan-mode-deep-dive.md +++ b/docs/plan-mode-deep-dive.md @@ -1,191 +1,244 @@ -# Plan Mode Implementation Plan +# Plan Mode Deep Dive ## Goal -Implement plan mode in a provider-agnostic way across: +Implement plan mode without fighting the architecture that already exists in this repo. -- Codex App Server -- Claude Agent SDK / Claude Code -- Cursor ACP +The current app is: -The implementation must support: +- provider-adapter driven on the server +- orchestration snapshot driven on the client +- already equipped with some plan-adjacent runtime events +- not yet equipped with first-class plan-mode state in the orchestration read model -- provider-native or adapter-derived plan mode state -- structured user-input question flows where available -- graceful fallback when a provider lacks structured prompts or structured plan updates -- a front-end UX that renders from capabilities and canonical events rather than provider-specific conditionals - -This document now serves as both: - -- the architecture plan for shared orchestration and UI wiring -- the provider adapter implementation plan for each supported agent runtime +This document updates the implementation plan to match that reality. --- -## Source of truth +## Current Repo Baseline -### Codex +### 1. Shared contracts already have some plan primitives -Codex protocol decisions should be based on the open-source app-server protocol, not on local rollout session files. +`packages/contracts/src/providerRuntime.ts` already includes: -Upstream Codex app-server explicitly supports: +- `turn.plan.updated` +- `content.delta` with `streamKind: "plan_text"` +- `user-input.requested` +- `user-input.resolved` -- `turn/plan/updated` -- `item/plan/delta` -- `item/tool/requestUserInput` -- `serverRequest/resolved` -- lower-level `EventMsg` variants `plan_update`, `plan_delta`, and `request_user_input` +Important constraint: + +- these are runtime-event primitives only +- there is no first-class provider capability schema +- there is no first-class plan operating mode field +- there is no dedicated final-plan artifact schema + +### 2. Existing `runtimeMode` is not plan mode + +`RuntimeMode` in `packages/contracts/src/orchestration.ts` and `packages/contracts/src/provider.ts` means execution policy: + +- `approval-required` +- `full-access` + +Plan mode must not overload that field. + +If we need provider operating mode, it needs a separate concept and separate naming, for example: + +- `interactionMode` +- `planningMode` +- `providerOperatingMode` + +Anything is acceptable as long as it is not reused from the existing access-policy `runtimeMode`. + +### 3. Server flow is already canonical-event-first + +The current server pipeline is: + +1. Provider adapters emit `ProviderRuntimeEvent`s. +2. `ProviderService` multiplexes adapter streams and maintains provider session bindings. +3. `ProviderRuntimeIngestion` translates runtime events into orchestration commands and thread activities. +4. `ProjectionPipeline` persists read-model tables. +5. `ProjectionSnapshotQuery` rebuilds the orchestration snapshot returned by `orchestration.getSnapshot`. +6. `wsServer` exposes generic orchestration snapshot, command dispatch, diff, and replay methods. + +This matters because plan mode should fit into the same pipeline, not bypass it with a parallel websocket protocol. + +Important detail: + +- `wsServer` broadcasts orchestration domain events, not raw provider runtime events +- if the web should see plan state live, that state must first become orchestration events and/or projections + +### 4. The current read model has nowhere to put plan mode + +`OrchestrationThread` currently contains: + +- `messages` +- `activities` +- `checkpoints` +- `session` +- `latestTurn` +- thread metadata like title, model, branch, worktree + +It does not contain: + +- current structured plan snapshot +- accumulated plan-text stream +- pending structured user-input prompt +- resolved structured answers +- provider interactive capabilities +- provider plan/default operating mode +- final approved plan artifact + +Today the only flexible place plan data can survive into the web snapshot is `activities[].payload`. + +### 5. The web app is snapshot-driven, not event-reduced + +The client flow today is: + +1. `EventRouter` listens to `orchestration.domainEvent`. +2. On domain events, it re-fetches `orchestration.getSnapshot`. +3. Zustand stores the latest normalized snapshot. +4. `session-logic.ts` derives UI state like pending approvals and work log from thread activities. +5. `ChatView` renders the thread from snapshot state plus those derived selectors. Important implication: -- local Codex rollout JSONL files are a lossy or higher-level projection -- they may preserve plan mode state and final `` output -- they should not be treated as proof that low-level plan events do or do not exist +- plan mode should be added to the orchestration snapshot and/or activity derivation path +- it should not depend on a separate client-side event reducer -### Claude +### 6. Current implementation status by provider -Claude protocol decisions should be based on: +#### Codex -- installed SDK typings -- real local session JSONL evidence +Currently implemented: -Confirmed from local Claude transcript: +- `turn/plan/updated` maps to `turn.plan.updated` +- `item/plan/delta` maps to `content.delta` with `streamKind: "plan_text"` -- `AskUserQuestion` is a native `tool_use` -- `ExitPlanMode` is a native `tool_use` -- `AskUserQuestion.input.questions[]` is structured and adapter-parseable +Current gap: -### Cursor +- `item/tool/requestUserInput` is currently classified as `request.opened` with request type `tool_user_input` +- it is not yet upgraded into `user-input.requested` +- there is no structured answer submission path yet -Cursor protocol decisions should be based on: +#### Claude Code -- ACP docs -- current local ACP probe output +Currently implemented: -Confirmed from current docs + probe: +- session/config metadata surfaces through `session.configured` +- assistant/reasoning text flows through normal runtime content events +- approval requests already bridge into `request.opened` / `request.resolved` -- ACP session modes include `agent`, `plan`, and `ask` -- for product semantics, treat ACP mode as binary: `operatingMode: default | plan` -- Cursor ACP extension methods include: - - `cursor/ask_question` (multiple-choice question prompt) - - `cursor/create_plan` (explicit plan approval request) - - `cursor/update_todos` (todo-state updates that can drive plan-step UI) -- probe streams still show standard chunk/update events and may not always emit extension methods in every run +Current gap: + +- no current canonical plan-mode mapping +- no current `AskUserQuestion` mapping +- no current `ExitPlanMode` mapping + +#### Cursor + +Currently implemented: + +- `available_commands_update` maps to `session.configured` +- thought/message chunks map to `content.delta` +- permission requests bridge into approval request events + +Current gap: + +- no current plan-mode mapping +- no current structured prompt mapping +- no current todo/plan projection --- -## High-level product model +## Design Constraints -The implementation should separate three concepts that are currently easy to conflate. +### Keep the current transport model -### 1. Operating mode +Plan mode should continue to flow through: -This is the agent runtime mode. +- `ProviderRuntimeEvent` +- orchestration commands/events +- projection tables +- `orchestration.getSnapshot` +- `orchestration.domainEvent` -Canonical operating mode: +Do not introduce provider-specific websocket channels unless the current architecture proves insufficient. -- `default` -- `plan` +### Keep the client snapshot-driven -This should be represented at runtime independently of plan content or prompt cards. +The client already assumes that server state is re-synced from snapshots. Plan mode should respect that. -### 2. Structured plan state +That means: -This is the current structured plan snapshot, if the provider can supply one natively or if the adapter can synthesize one confidently. +- server-side persistence matters more than clever client buffering +- reconnect behavior should come from projection state, not ad hoc React state +- `session-logic.ts` and `ChatView` are the main frontend extension seams -Canonical model: +### Keep approvals separate from structured user input -```ts -interface CanonicalPlanState { - explanation?: string | null; - steps: Array<{ - id?: string; - text: string; - status: 'pending' | 'inProgress' | 'completed'; - source: 'native' | 'synthesized'; - }>; -} -``` +The current system has a full approval pipeline: -### 3. Structured user-input prompt +- runtime `request.opened` / `request.resolved` +- orchestration `thread.approval.respond` +- provider `respondToRequest(...)` -This is the product-level question card model. +Plan-mode structured prompts are different and should not be forced into the approval model unless a provider genuinely models them as approval requests. -Canonical model: +### Preserve raw provider payloads -```ts -interface CanonicalUserInputPrompt { - promptId: string; - title?: string; - description?: string; - questions: Array<{ - id: string; - header?: string; - label: string; - description?: string; - options: Array<{ - id: string; - label: string; - description?: string; - recommended?: boolean; - }>; - multiSelect?: boolean; - allowFreeform?: boolean; - freeformPlaceholder?: string; - required?: boolean; - }>; - source: 'native' | 'tool-derived' | 'synthesized'; -} - -interface CanonicalUserInputAnswer { - promptId: string; - answers: Array<{ - questionId: string; - selectedOptionIds?: string[]; - text?: string; - }>; -} -``` +When adding plan-mode mappings: -This should remain distinct from approvals. +- keep `raw` on internal canonical runtime events +- keep `providerRefs` on internal canonical runtime events +- avoid throwing away native payload shape too early inside adapter/server processing + +This does not mean provider-specific payloads should leak into the orchestration read model, websocket API, or web UI. + +The boundary should stay: + +- adapter/server internals may retain native payloads for correlation, debugging, and future parser improvements +- shared orchestration state and client-visible contracts should expose only canonical plan-mode data + +That matters most for Claude and Cursor, where some mappings will be adapter-derived and may need native context during implementation and debugging. --- -## Shared orchestration plan +## Updated Shared Work Plan -## Summary +## 1. Tighten shared contract naming before wiring features -Implement a canonical plan-mode interaction pipeline that starts in provider adapters, flows through provider runtime ingestion and orchestration projection, and terminates in capability-driven frontend rendering and response submission. +The first step is not to invent a new architecture. It is to formalize the concepts that are currently missing from contracts. -### Shared goals +### Add a plan operating mode concept -- normalize plan mode state from each provider -- normalize structured prompts where possible -- preserve native payloads for later refinement -- avoid UI coupling to provider names -- support degraded conversational fallback where native structured prompts are unavailable +Add a new shared contract for provider plan/default mode. ---- +Requirements: -## Orchestration work items +- do not reuse `RuntimeMode` +- keep it orthogonal to access policy +- make it available in the server->web read model, not only in raw runtime payloads + +Preferred shape: + +```ts +type ProviderInteractionMode = "default" | "plan"; +``` -### 1. Extend canonical provider runtime contracts +### Add typed provider interactive capabilities -Files: +Current `session.configured.payload.config` is just `Record`. That is too weak for capability-driven UI. -- `packages/contracts/src/providerRuntime.ts` -- `packages/contracts/src/provider.ts` -- `EVENTS.md` +Add a typed capability surface in `packages/contracts` that both server and web can import directly. -Add or formalize the following runtime concepts: +Preferred shape: -- provider interactive capability payload -- plan-mode state event or mode metadata surface -- canonical structured user-input payload schema -- canonical structured user-input answer payload schema -- final-plan handoff payload for providers that emit a completed plan as text or tool output +- exported constant/object keyed by `ProviderKind` +- exported type derived from that object +- usable by adapters, orchestration code, and frontend rendering logic without duplicating provider capability tables -Required additions: +Minimum fields: ```ts interface ProviderInteractiveCapabilities { @@ -194,460 +247,318 @@ interface ProviderInteractiveCapabilities { supportsPlanTextStreaming: boolean; supportsStructuredUserInput: boolean; supportsFreeformUserInput: boolean; - supportsExitPlanMode: boolean; + supportsPlanAcceptance: boolean; } ``` -Canonical runtime behavior: +Preferred usage: -- `request.opened/request.resolved` remain transport-oriented -- `user-input.requested/user-input.resolved` remain product-oriented -- `turn.plan.updated` remains the canonical structured plan update event -- `content.delta(streamKind=plan_text)` remains the canonical streaming plan text surface +- static provider capabilities live in shared contracts +- adapters may still emit dynamic runtime metadata when a capability is session-specific or probe-dependent +- the orchestration snapshot should expose the active provider and any dynamic overrides, not duplicate the full static capability catalog per thread -### 2. Add canonical mode/capability publication from providers +### Add a dedicated structured-answer command path -Files: +Current shared commands only support approvals: -- `apps/server/src/provider/Layers/*Adapter.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` +- `thread.approval.respond` +- provider `respondToRequest(...)` -Each adapter should publish provider capabilities and current operating mode early in session startup and when mode changes. +Plan mode needs a distinct path for structured prompt answers. -Preferred shape: +Preferred additions: -- capabilities included in `session.configured` -- current mode included in `session.configured`, `session.state.changed`, or a dedicated mode field on turn/session runtime metadata +- orchestration command such as `thread.user-input.respond` +- provider service method such as `respondToUserInput(...)` +- adapter hook per provider for native answer serialization -### 3. Preserve and project plan/user-input state in orchestration +Do not overload approval decisions like `accept` / `decline` for structured question answers. -Files: +## 2. Extend the orchestration read model -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts` -- any orchestration projection models feeding the web socket domain events +The current read model is the main missing piece. -Implementation requirements: +Plan mode needs first-class projection state so reconnect and refresh behave like the rest of the app. -- persist latest structured plan snapshot per active turn -- persist pending structured user-input prompts per turn -- persist resolved answers for history rendering -- preserve raw/native payloads for debugging and future adapter improvements +### Add first-class thread plan state -### 4. Add frontend state model for plan mode +Preferred thread-level additions: -Files likely involved: +- `interactionMode` +- `currentPlan` +- `pendingUserInput` +- `resolvedUserInputs` +- optional `finalPlanArtifact` -- web app session/thread stores -- conversation/event rendering layer -- input composer / pending interaction surfaces +The exact nesting can vary, but it should be snapshot-friendly and not require replaying arbitrary activity payloads in the client. -Add frontend state for: +`interactiveCapabilities` should not be modeled as thread-owned state if they are static per provider. -- current provider capabilities -- current operating mode +Preferred split: + +- static capabilities: shared contract export keyed by provider +- dynamic per-thread/per-session state: only fields that can actually vary at runtime, such as current interaction mode or active pending prompt + +### Keep activities as secondary audit trail + +`thread.activities` should still receive plan/user-input activity summaries for timeline/debugging, but it should not be the only source of truth for active plan state. + +### Projection changes + +This likely means: + +- new projection repository/table(s) for plan state and prompt state +- `ProjectionPipeline` updates +- `ProjectionSnapshotQuery` returning the new fields +- projection tests for persistence and replay + +## 3. Update runtime ingestion to populate that state + +`ProviderRuntimeIngestion` already knows how to: + +- turn assistant text into messages +- turn approval events into activities +- turn `turn.plan.updated` into an activity + +It should be extended to also translate plan/user-input runtime events into read-model state. + +Implementation direction: + +- `turn.plan.updated` updates projected plan snapshot +- `content.delta(plan_text)` appends to projected plan-text buffer when relevant +- `user-input.requested` opens a projected pending prompt +- `user-input.resolved` closes/resolves that prompt +- providers that only expose raw/native blobs can still emit synthesized canonical runtime events first, then rely on the shared ingestion path + +## 4. Integrate plan mode into the existing web seams + +The web architecture already has the right places. + +### Store and types + +Update: + +- `apps/web/src/types.ts` +- `apps/web/src/store.ts` + +The store should stay thin and snapshot-oriented. It should sync whatever new read-model fields the server exposes. + +### Derived UI state + +Update: + +- `apps/web/src/session-logic.ts` + +This is the natural place for: + +- `derivePlanState(...)` +- `derivePendingUserInput(...)` +- any fallback derivation from activities during an incremental migration + +### Rendering + +Primary UI seam: + +- `apps/web/src/components/ChatView.tsx` + +Current UI already has: + +- top-of-thread alert stacks for pending approvals +- work-log rendering derived from activities +- timeline rows for messages and work state + +That suggests two viable rendering patterns: + +1. active plan/prompt panel above the timeline +2. resolved plan/prompt entries rendered in timeline/history + +The route structure does not need to change. Plan mode should remain thread-local state inside the existing chat route. + +--- + +## Provider Implementation Plan + +## Provider 1: Codex + +Codex is closest to the target shape and should be the first end-to-end implementation. + +### Current status + +Already present: + +- native structured plan update mapping +- native plan-text stream mapping + +Missing: + +- native structured prompt mapping for `item/tool/requestUserInput` +- structured answer submission path +- removal of the current empty-answer auto-ack path in `codexAppServerManager` +- projection and UI support for the already-emitted plan events + +### Required work + +1. Upgrade `item/tool/requestUserInput` from request-only handling to canonical `user-input.requested`. +2. Preserve `request.opened` as transport/debug metadata when useful. +3. Add adapter-side answer submission for the corresponding pending request/tool context. +4. Emit `user-input.resolved` when the answer is submitted or confirmed resolved. +5. Project the result into the orchestration snapshot. + +### Codex-specific note + +The runtime event schemas already match Codex reasonably well, so this provider should define the canonical end-to-end behavior first. + +## Provider 2: Claude Code + +Claude likely needs tool-aware adaptation rather than pure transport mapping. + +### Current status + +Already present: + +- generic session/runtime stream integration +- approval bridging + +Missing: + +- plan/default operating mode mapping +- `AskUserQuestion` -> `user-input.requested` +- `ExitPlanMode` -> final-plan or plan-state handoff + +### Required work + +1. Detect plan/default operating mode from Claude-native metadata without touching access-policy `runtimeMode`. +2. Map `AskUserQuestion` tool payloads into canonical `user-input.requested`. +3. Add provider-specific answer serialization for the return path. +4. Detect `ExitPlanMode` and decide whether it becomes: + - a final-plan artifact + - a synthetic structured plan snapshot + - or both +5. Avoid over-synthesizing incremental structured plan steps unless Claude gives us enough structure to do it safely. + +## Provider 3: Cursor + +Cursor should start from the same canonical server pipeline, but with a more conservative adapter. + +### Current status + +Already present: + +- session/update stream handling +- reasoning/assistant text mapping +- approval bridging + +Missing: + +- plan/default operating mode mapping +- structured prompt mapping +- structured plan/todo mapping + +### Required work + +1. Detect and publish Cursor operating mode separately from access-policy runtime mode. +2. Map documented extension methods such as `cursor/ask_question` and `cursor/update_todos` only when payloads are confirmed. +3. Fall back to plan-mode indicator plus normal assistant/reasoning text if structured Cursor plan events are absent. +4. Preserve raw ACP payloads so the adapter can improve without changing higher layers. + +--- + +## Frontend Rendering Plan + +The frontend should remain capability-driven, but using real repo seams. + +### Active state + +Render from projected snapshot fields: + +- current plan/default operating mode +- provider capabilities +- current structured plan +- current plan-text stream - pending structured prompt -- structured plan snapshot -- streaming plan text buffer -- final plan handoff state when present -### 5. Implement capability-driven UI rendering +### History state -UI rules: +Render from: -- if `supportsStructuredUserInput`, render the multi-question card UI -- if not, fall back to standard conversational reply flow -- if `supportsStructuredPlanUpdates`, render status-tracked plan steps -- if only `supportsPlanTextStreaming`, render streaming plan prose -- if neither exists, render standard assistant content in plan mode with clear mode indicator +- normal messages +- resolved prompt answers +- plan-related activity/history rows +- optional final-plan artifact -### 6. Implement front-to-back response submission path +### Placement -Requirements: +Recommended initial placement: -- question card submit sends canonical answer payload to server -- server routes answer to the correct provider adapter pending request/tool context -- resolved provider response emits both: - - `user-input.resolved` - - `request.resolved` when applicable +1. Active pending prompt and active plan panel above the timeline, near the current approvals stack. +2. Resolved prompt/plan history in the timeline. -### 7. History and reconnect behavior - -Requirements: - -- pending structured prompts should survive reconnect/resubscribe if still active -- resolved prompts should be rendered in history as completed interactions -- plan snapshots should be replayable from persisted orchestration activity -- current streaming plan text should resume cleanly on reconnect if provider continues emitting deltas - ---- - -## Provider adapter plans - -## Provider 1: Codex App Server - -### Summary - -Codex is the reference implementation and should be wired as the strongest native provider. - -### Native protocol mapping - -Source of truth: upstream open-source app-server protocol. - -Native incoming surfaces: - -- `turn/plan/updated` -- `item/plan/delta` -- `item/tool/requestUserInput` -- `serverRequest/resolved` -- lower-level `EventMsg.plan_update` -- lower-level `EventMsg.plan_delta` -- lower-level `EventMsg.request_user_input` - -### Adapter implementation - -Files: - -- `apps/server/src/provider/Layers/CodexAdapter.ts` -- related tests in `apps/server/src/provider/Layers/*Codex*.test.ts` - -Implementation requirements: - -1. Keep native structured plan updates as-is: -- `turn/plan/updated` -> `turn.plan.updated` - -2. Keep native plan text streaming as-is: -- `item/plan/delta` -> `content.delta` with `streamKind: "plan_text"` - -3. Keep native structured user-input as-is: -- `item/tool/requestUserInput` -> `user-input.requested` -- also emit `request.opened` with a transport request type - -4. On client answer submission: -- route answer to the corresponding Codex pending request id -- emit `user-input.resolved` -- observe/forward `serverRequest/resolved` -> `request.resolved` - -5. Publish capabilities: -- `supportsPlanMode = true` -- `supportsStructuredPlanUpdates = true` -- `supportsPlanTextStreaming = true` -- `supportsStructuredUserInput = true` -- `supportsFreeformUserInput = true` if answer payload supports note/text in practice -- `supportsExitPlanMode = false` unless a separate explicit tool/event is introduced - -### Codex-specific tests - -- `turn/plan/updated` maps to canonical structured plan state -- `item/plan/delta` maps to `plan_text` -- `item/tool/requestUserInput` maps to canonical structured prompt shape -- answer response resolves pending request and emits both canonical resolved events -- reconnect/history replay preserves plan snapshot and pending prompt - ---- - -## Provider 2: Claude Agent SDK / Claude Code - -### Summary - -Claude requires tool-aware adaptation. Native structured interaction exists, but it is surfaced through `tool_use` blocks rather than dedicated transport events equivalent to Codex. - -### Real native evidence - -From the local Claude session transcript: - -- `AskUserQuestion` arrives as: - - assistant message - - content block type `tool_use` - - `name: "AskUserQuestion"` - - `input.questions[]` -- `ExitPlanMode` arrives as: - - assistant message - - content block type `tool_use` - - `name: "ExitPlanMode"` - - `input.plan` containing finalized plan text/spec - -Claude also has native: - -- `permissionMode: 'plan'` -- `system:init.permissionMode` -- `system:status.permissionMode` - -### Adapter implementation - -Files: - -- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` -- `apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts` - -Implementation requirements: - -1. Publish native operating mode: -- `permissionMode: 'plan'` -> canonical mode `plan` -- all other values -> canonical mode `default` - -2. Detect `AskUserQuestion` tool uses: -- inspect `tool_use` blocks in assistant messages / stream events -- when `name === 'AskUserQuestion'`, convert `input.questions[]` to canonical `user-input.requested` -- also emit `request.opened` using a Claude-specific tool-user-input request type - -3. Canonical question mapping for Claude: -- `header` -> canonical `header` -- `question` -> canonical `label` -- `options[].label` -> canonical option label -- `options[].description` -> canonical option description -- `multiSelect` -> canonical `multiSelect` -- source = `tool-derived` - -4. Implement answer submission path: -- on UI answer submit, convert canonical answers back into whatever Claude expects for the `AskUserQuestion` tool result path -- emit `user-input.resolved` -- emit `request.resolved` - -5. Detect `ExitPlanMode` tool uses: -- when `name === 'ExitPlanMode'`, parse `input.plan` -- emit a final plan handoff event and/or synthesize a `turn.plan.updated` snapshot if safe -- treat this as the boundary between planning and implementation readiness - -6. Structured plan behavior: -- Claude does not currently appear to expose a native `turn/plan/updated` equivalent -- initial implementation should not over-synthesize incremental steps -- use two-tier strategy: - - tier 1: preserve plan-related assistant text in `plan_text` only if clearly attributable - - tier 2: optionally synthesize `turn.plan.updated` from explicit structured plan strings only when parsing is robust - -7. Publish capabilities: -- `supportsPlanMode = true` -- `supportsStructuredPlanUpdates = partial/false initially` -- `supportsPlanTextStreaming = partial` -- `supportsStructuredUserInput = true` -- `supportsFreeformUserInput = unknown/false until answer path confirmed` -- `supportsExitPlanMode = true` - -### Claude-specific tests - -- `system:init` and `system:status` publish plan mode metadata -- `AskUserQuestion` tool maps into canonical prompt schema -- multi-select and option descriptions round-trip correctly -- answer submission resolves the pending prompt -- `ExitPlanMode` tool emits final plan handoff state -- ordinary tool uses do not get misclassified as prompts or plan exits - -### Claude-specific open question to resolve during implementation - -- exact runtime shape expected for the answer payload returned to `AskUserQuestion` - -Implementation default: - -- build adapter abstraction so only the final answer-serialization function is provider-specific and easily swappable once runtime shape is confirmed - ---- - -## Provider 3: Cursor ACP - -### Summary - -Cursor should ship with binary plan-mode semantics (`default | plan`) and implement documented ACP extension methods when present. If extension methods are absent in a given session, it should degrade cleanly to mode + streamed assistant text. - -### Real native evidence - -From the Cursor ACP docs + current probe: - -- available modes include `agent`, `plan`, and `ask` -- Cursor ACP docs define extension methods: - - `cursor/ask_question` - - `cursor/create_plan` - - `cursor/update_todos` -- observed updates include: - - `available_commands_update` - - `agent_thought_chunk` - - `agent_message_chunk` - - `tool_call` - - `tool_call_update` -- observed request type: - - `session/request_permission` - -Current caveats: - -- no standard ACP event equivalent to Codex `turn/plan/updated` -- no standard ACP event equivalent to Codex `item/plan/delta` -- extension-method payload shapes should be fixture-captured from live sessions before locking parser assumptions - -### Adapter implementation - -Files: - -- `apps/server/src/provider/Layers/CursorAdapter.ts` -- `apps/server/src/provider/Layers/CursorAdapter.test.ts` -- probe scripts under `scripts/` - -Implementation requirements: - -1. Publish native operating mode: -- map ACP `plan` mode -> canonical `plan` -- map ACP `agent` and `ask` modes -> canonical `default` -- preserve raw ACP mode in native metadata for debugging - -2. Implement documented Cursor extension methods: -- `cursor/ask_question` -> canonical `user-input.requested` -- `cursor/update_todos` -> canonical `turn.plan.updated` when payload is parseable into stable step ids/text/status -- `cursor/create_plan` -> canonical final-plan handoff / approval-request surface - -3. Use graceful fallback behavior: -- render plan mode as mode state + assistant text stream -- if no `cursor/ask_question` event is emitted, fall back to normal conversational input -- if no parseable `cursor/update_todos` event is emitted, fall back to text-only plan rendering - -4. Preserve room for future enrichment: -- keep raw ACP notifications available in native event logs -- extend the ACP probe to search for hidden or uncommon prompt/mode-change surfaces - -5. Publish capabilities: -- `supportsPlanMode = true` -- `supportsStructuredPlanUpdates = true` when `cursor/update_todos` is available, otherwise false -- `supportsPlanTextStreaming = false` initially (no distinct plan-text channel) -- `supportsStructuredUserInput = true` when `cursor/ask_question` is available, otherwise false -- `supportsFreeformUserInput = false` via structured prompt path -- `supportsExitPlanMode = false` - -### Cursor-specific tests - -- ACP mode metadata maps to `plan` vs `default` correctly -- `cursor/ask_question` maps into canonical structured prompt schema -- `cursor/update_todos` maps into canonical structured plan state when parseable -- `cursor/create_plan` maps into final-plan handoff / approval surface -- `agent_thought_chunk` and `agent_message_chunk` still render normally in plan mode -- fallback conversational flow remains functional - -### Cursor-specific follow-up probe work - -Add dedicated probes for: - -- mode-switching during an active session -- real payload shapes for `cursor/ask_question`, `cursor/create_plan`, and `cursor/update_todos` -- any request types besides `session/request_permission` -- whether extension methods are always emitted or only for specific prompt styles - ---- - -## Frontend implementation plan - -### Summary - -Render plan mode through a single UI model driven by canonical events and provider capabilities. - -### UI states to support - -- standard conversation -- plan mode with structured steps -- plan mode with text-only plan stream -- pending structured question card -- resolved question card in history -- final plan handoff / completed plan artifact - -### Rendering rules - -1. Show operating mode indicator whenever current mode is `plan` -2. Show structured question card only when a pending `user-input.requested` exists -3. Show structured plan step list when a current `turn.plan.updated` snapshot exists -4. Append `plan_text` streaming content beneath or alongside structured steps when both exist -5. Fall back to assistant text rendering when provider capabilities do not support structure -6. Show completed selected answers in history after `user-input.resolved` - -### Submission behavior - -- option click / freeform answer submits canonical answer payload -- disable duplicate submits while request is pending resolution -- preserve pending-card state across reconnects if request remains open - ---- - -## Data flow end to end - -1. Provider starts session and publishes capabilities + mode -2. User starts plan-mode turn or provider enters plan mode -3. Adapter emits structured plan and/or plan text events when available -4. Adapter emits `user-input.requested` when provider asks a structured question -5. Frontend renders question card or fallback conversational prompt based on capabilities -6. User submits answer -7. Server routes answer back to provider adapter pending request/tool context -8. Adapter emits `user-input.resolved` and `request.resolved` -9. Provider may continue planning, emit more plan updates, or emit final plan handoff -10. Frontend renders final completed plan state and history +This fits the existing `ChatView` layout with the least architectural churn. --- -## Test plan +## Test Plan ### Contracts -Files: - -- `packages/contracts/src/providerRuntime.test.ts` -- related schema tests - Add coverage for: -- provider capabilities schema -- structured user-input prompt schema -- structured user-input answer schema -- mode metadata schema +- new interaction-mode schema +- new capability schema +- new structured-answer command schema +- any read-model additions for active plan/prompt state -### Server/provider tests +Retain existing coverage for: -Files: +- `turn.plan.updated` +- `content.delta(plan_text)` +- `user-input.requested` +- `user-input.resolved` -- `apps/server/src/provider/Layers/CodexAdapter.test.ts` -- `apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts` -- `apps/server/src/provider/Layers/CursorAdapter.test.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts` +### Server -Add coverage for: +Add or extend tests for: -- capabilities emission -- mode propagation -- plan update propagation -- plan text propagation -- structured prompt lifecycle -- answer resolution lifecycle -- reconnect/history replay behavior +- provider adapter plan/prompt mapping +- `ProviderRuntimeIngestion` plan/prompt projection behavior +- `ProjectionPipeline` persistence and replay +- `ProjectionSnapshotQuery` returning active plan state +- provider answer submission path -### Web/UI tests +### Web -Add coverage for: +Add or extend tests for: -- question card rendering from canonical prompt -- question answer submission -- resolved question history rendering -- structured plan step rendering -- text-only plan rendering -- capability-based fallback rendering for Cursor +- `syncServerReadModel` with new plan fields +- `session-logic.ts` derivation of active plan/prompt state +- `ChatView` rendering of: + - active plan panel + - pending structured prompt + - resolved prompt history + - plan-text fallback -### Probe / fixture tests +--- -- preserve the Claude transcript-derived `AskUserQuestion` and `ExitPlanMode` payloads as fixtures -- preserve Codex protocol fixtures for `turn/plan/updated`, `item/plan/delta`, and `item/tool/requestUserInput` -- preserve ACP probe summaries as fixtures for unsupported-capability assertions +## Recommended Execution Order ---- +1. Add shared contract types for interaction mode, capabilities, and structured-answer submission. +2. Extend orchestration read-model contracts and projection storage for active plan/prompt state. +3. Wire `ProviderRuntimeIngestion` into those projections. +4. Finish Codex end-to-end first, since it already emits most of the canonical runtime pieces. +5. Add frontend snapshot sync, derivation, and rendering in `types.ts`, `store.ts`, `session-logic.ts`, and `ChatView.tsx`. +6. Add Claude adapter mapping. +7. Add Cursor adapter mapping and fallback behavior. -## Assumptions and defaults +## Short version -- Codex is the reference provider for full structured plan/question UX. -- Claude supports structured prompts via tool adaptation, but incremental structured plan updates are not assumed initially. -- Cursor transport exposes `agent|plan|ask`, but product semantics collapse this to `operatingMode: default | plan`. -- Cursor structured prompts/plan steps are driven by ACP extension methods when present, with fallback when absent. -- Product/UI should degrade gracefully rather than invent unsupported provider behavior. -- Raw provider payloads should always be retained where feasible to support future adapter refinement. +The repo is already close in one specific sense: the runtime event vocabulary for plans and structured prompts exists. ---- +The real missing architecture is: -## Recommended execution order +- a separate plan/default operating mode concept +- typed provider capabilities +- a first-class structured-answer command path +- read-model projection for active plan/prompt state +- web rendering sourced from that snapshot state -1. Formalize canonical capabilities + mode + structured prompt contracts -2. Wire capabilities/mode through orchestration and web socket projections -3. Finish Codex end-to-end plan mode implementation first -4. Implement Claude `AskUserQuestion` and `ExitPlanMode` adapter mapping -5. Add Cursor binary (`default|plan`) mode support + ACP extension-method mapping -6. Build capability-driven frontend rendering and answer submission -7. Add reconnect/history coverage and transcript/protocol fixtures +That is the shape the implementation should follow. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bf129533c58e3af78ac25f589c16c720aa90be5b..92f5b502c942d74d167354128b32b2f606ac48a4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -14,7 +14,12 @@ import type { GitStatusInput, GitStatusResult, } from "./git"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; +import type { + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "./project"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -104,6 +109,7 @@ export interface NativeApi { }; projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; + writeFile: (input: ProjectWriteFileInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index a7b7c63538685e1fa203a12891362f4d27fec519..057f498d880b270c4c6ad04f822f062c490ee005 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { + DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, OrchestrationGetTurnDiffInput, OrchestrationSession, @@ -110,6 +111,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () }); assert.strictEqual(parsed.provider, undefined); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), ); @@ -131,6 +133,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( }); assert.strictEqual(parsed.provider, "claudeCode"); assert.strictEqual(parsed.runtimeMode, "full-access"); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), ); @@ -150,10 +153,11 @@ it.effect("accepts cursor provider in thread.turn.start", () => createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.provider, "cursor"); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), ); -it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () => +it.effect("decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartRequestedPayload({ threadId: "thread-1", @@ -162,6 +166,7 @@ it.effect("decodes thread.turn-start-requested defaults for provider and runtime }); assert.strictEqual(parsed.provider, undefined); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 33fa23244d20ae57ef0c007c70ffaf8fb85d6652..8600f5b91dd0e56ac19856c3375a2bb9ce3e9263 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -32,6 +32,9 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; +export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); +export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; +export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); @@ -43,6 +46,8 @@ export const ProviderApprovalDecision = Schema.Literals([ "cancel", ]); export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; +export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); +export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; @@ -219,6 +224,9 @@ export const OrchestrationThread = Schema.Struct({ title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), latestTurn: Schema.NullOr(OrchestrationLatestTurn), @@ -274,6 +282,9 @@ export const ThreadCreateCommand = Schema.Struct({ title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, @@ -303,6 +314,14 @@ export const ThreadRuntimeModeSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadInteractionModeSetCommand = Schema.Struct({ + type: Schema.Literal("thread.interaction-mode.set"), + commandId: CommandId, + threadId: ThreadId, + interactionMode: ProviderInteractionMode, + createdAt: IsoDateTime, +}); + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -318,6 +337,9 @@ export const ThreadTurnStartCommand = Schema.Struct({ effort: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), createdAt: IsoDateTime, }); @@ -336,6 +358,7 @@ export const ClientThreadTurnStartCommand = Schema.Struct({ effort: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, createdAt: IsoDateTime, }); @@ -356,6 +379,15 @@ export const ThreadApprovalRespondCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadUserInputRespondCommand = Schema.Struct({ + type: Schema.Literal("thread.user-input.respond"), + commandId: CommandId, + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + createdAt: IsoDateTime, +}); + export const ThreadCheckpointRevertCommand = Schema.Struct({ type: Schema.Literal("thread.checkpoint.revert"), commandId: CommandId, @@ -379,9 +411,11 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, + ThreadInteractionModeSetCommand, ThreadTurnStartCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, + ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); @@ -396,9 +430,11 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, + ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, + ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); @@ -485,10 +521,12 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.meta-updated", "thread.runtime-mode-set", + "thread.interaction-mode-set", "thread.message-sent", "thread.turn-start-requested", "thread.turn-interrupt-requested", "thread.approval-response-requested", + "thread.user-input-response-requested", "thread.checkpoint-revert-requested", "thread.reverted", "thread.session-stop-requested", @@ -532,6 +570,9 @@ export const ThreadCreatedPayload = Schema.Struct({ title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, @@ -558,6 +599,14 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadInteractionModeSetPayload = Schema.Struct({ + threadId: ThreadId, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), + updatedAt: IsoDateTime, +}); + export const ThreadMessageSentPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, @@ -578,6 +627,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ effort: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), createdAt: IsoDateTime, }); @@ -594,6 +646,13 @@ export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadUserInputResponseRequestedPayload = Schema.Struct({ + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + createdAt: IsoDateTime, +}); + export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ threadId: ThreadId, turnCount: NonNegativeInt, @@ -702,6 +761,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.runtime-mode-set"), payload: ThreadRuntimeModeSetPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.interaction-mode-set"), + payload: ThreadInteractionModeSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.message-sent"), @@ -722,6 +786,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.approval-response-requested"), payload: ThreadApprovalResponseRequestedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.user-input-response-requested"), + payload: ThreadUserInputResponseRequestedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.checkpoint-revert-requested"), @@ -791,6 +860,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ eventType: Schema.Literal("thread.runtime-mode-set"), payload: ThreadRuntimeModeSetPayload, }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.interaction-mode-set"), + payload: ThreadInteractionModeSetPayload, + }), Schema.Struct({ ...PersistedEventBaseFields, eventType: Schema.Literal("thread.message-sent"), @@ -811,6 +885,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ eventType: Schema.Literal("thread.approval-response-requested"), payload: ThreadApprovalResponseRequestedPayload, }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.user-input-response-requested"), + payload: ThreadUserInputResponseRequestedPayload, + }), Schema.Struct({ ...PersistedEventBaseFields, eventType: Schema.Literal("thread.checkpoint-revert-requested"), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f3468d55d06e2e31c831f9282d59b3e189d85246..7a1b2d01ba606e88d02849437bd02cf466fffd1a 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +export const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -24,3 +25,17 @@ export const ProjectSearchEntriesResult = Schema.Struct({ truncated: Schema.Boolean, }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; + +export const ProjectWriteFileInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH), + ), + contents: Schema.String, +}); +export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type; + +export const ProjectWriteFileResult = Schema.Struct({ + relativePath: TrimmedNonEmptyString, +}); +export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index c9d35497ebce86ac7ad1534aed3558f969f38380..2dd1dca94a4ab73e1b140061d169755ecf9ab97a 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -14,8 +14,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderApprovalDecision, + ProviderInteractionMode, ProviderKind, ProviderRequestKind, + ProviderUserInputAnswers, RuntimeMode, TurnCountRange, } from "./orchestration"; @@ -92,6 +94,7 @@ export const ProviderSendTurnInput = Schema.Struct({ ), model: Schema.optional(TrimmedNonEmptyStringSchema), effort: Schema.optional(TrimmedNonEmptyStringSchema), + interactionMode: Schema.optional(ProviderInteractionMode), }); export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; @@ -169,6 +172,13 @@ export const ProviderRespondToRequestInput = Schema.Struct({ }); export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; +export const ProviderRespondToUserInputInput = Schema.Struct({ + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, +}); +export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; + export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); export type ProviderEventKind = typeof ProviderEventKind.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 7f94615bdd47cc146697ac238a5b4590627b4c4f..61319981bdbd7758dd652962919168a6379cef59 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -29,7 +29,7 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { ProjectSearchEntriesInput } from "./project"; +import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -40,6 +40,7 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", + projectsWriteFile: "projects.writeFile", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -101,6 +102,7 @@ export const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), + tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), // Shell methods tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),