| author | |
| committer | |
| log | 12edc3455cf675d2e8000f508b38b79bc7743a88 |
| tree | 7bd724608b2be8a5a58ec5de3340c8769dd27ba3 |
| parent | 26f631b638205506d292c829feaf5d539e05804d |
- Wire Codex plan mode through turn start, including collaboration settings
- Track and answer pending `requestUserInput` prompts instead of auto-empty responses
- Persist/project thread `interactionMode` (schema, migration, queries) and update tests/contracts57 files changed, 4625 insertions(+), 641 deletions(-)
apps/server/integration/TestProviderAdapter.integration.ts+7| ... | ... | @@ -413,6 +413,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 413 | 413 | }) |
| 414 | 414 | : missingSessionEffect(provider, threadId); |
| 415 | 415 | |
| 416 | const respondToUserInput: ProviderAdapterShape<ProviderAdapterError>["respondToUserInput"] = ( | |
| 417 | threadId, | |
| 418 | _requestId, | |
| 419 | _answers, | |
| 420 | ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId)); | |
| 421 | ||
| 416 | 422 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) => |
| 417 | 423 | Effect.sync(() => { |
| 418 | 424 | sessions.delete(threadId); |
| ... | ... | @@ -475,6 +481,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 475 | 481 | sendTurn, |
| 476 | 482 | interruptTurn, |
| 477 | 483 | respondToRequest, |
| 484 | respondToUserInput, | |
| 478 | 485 | stopSession, |
| 479 | 486 | listSessions, |
| 480 | 487 | hasSession, |
apps/server/integration/orchestrationEngine.integration.test.ts+6| ... | ... | @@ -4,6 +4,7 @@ import path from "node:path"; |
| 4 | 4 | import { |
| 5 | 5 | ApprovalRequestId, |
| 6 | 6 | CommandId, |
| 7 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 7 | 8 | EventId, |
| 8 | 9 | MessageId, |
| 9 | 10 | ProjectId, |
| ... | ... | @@ -118,6 +119,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 118 | 119 | projectId: PROJECT_ID, |
| 119 | 120 | title: "Integration Thread", |
| 120 | 121 | model: "gpt-5-codex", |
| 122 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 121 | 123 | runtimeMode: "approval-required", |
| 122 | 124 | branch: null, |
| 123 | 125 | worktreePath: harness.workspaceDir, |
| ... | ... | @@ -143,6 +145,7 @@ const startTurn = (input: { |
| 143 | 145 | attachments: [], |
| 144 | 146 | }, |
| 145 | 147 | ...(input.provider !== undefined ? { provider: input.provider } : {}), |
| 148 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 146 | 149 | runtimeMode: "approval-required", |
| 147 | 150 | createdAt: nowIso(), |
| 148 | 151 | }); |
| ... | ... | @@ -241,6 +244,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 241 | 244 | projectId: PROJECT_ID, |
| 242 | 245 | title: "Integration Thread", |
| 243 | 246 | model: "gpt-5.3-codex", |
| 247 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 244 | 248 | runtimeMode: "full-access", |
| 245 | 249 | branch: null, |
| 246 | 250 | worktreePath: harness.workspaceDir, |
| ... | ... | @@ -257,6 +261,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 257 | 261 | text: "Reply with exactly ALPHA.", |
| 258 | 262 | attachments: [], |
| 259 | 263 | }, |
| 264 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 260 | 265 | runtimeMode: "full-access", |
| 261 | 266 | createdAt: nowIso(), |
| 262 | 267 | }); |
| ... | ... | @@ -283,6 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 283 | 288 | text: "Reply with exactly BETA.", |
| 284 | 289 | attachments: [], |
| 285 | 290 | }, |
| 291 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 286 | 292 | runtimeMode: "approval-required", |
| 287 | 293 | createdAt: nowIso(), |
| 288 | 294 | }); |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | CheckpointRef, |
| 3 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 3 | 4 | ProjectId, |
| 4 | 5 | ThreadId, |
| 5 | 6 | TurnId, |
| ... | ... | @@ -43,6 +44,7 @@ function makeSnapshot(input: { |
| 43 | 44 | projectId: input.projectId, |
| 44 | 45 | title: "Thread", |
| 45 | 46 | model: "gpt-5-codex", |
| 47 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 46 | 48 | runtimeMode: "full-access", |
| 47 | 49 | branch: null, |
| 48 | 50 | worktreePath: input.worktreePath, |
apps/server/src/codexAppServerManager.test.ts+129-1| ... | ... | @@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto"; |
| 3 | 3 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | 4 | import os from "node:os"; |
| 5 | 5 | import path from "node:path"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | |
| 6 | import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; | |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | buildCodexInitializeParams, | |
| 10 | CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, | |
| 9 | 11 | CodexAppServerManager, |
| 10 | 12 | classifyCodexStderrLine, |
| 11 | 13 | isRecoverableThreadResumeError, |
| ... | ... | @@ -22,6 +24,7 @@ function createSendTurnHarness() { |
| 22 | 24 | provider: "codex", |
| 23 | 25 | status: "ready", |
| 24 | 26 | threadId: "thread_1", |
| 27 | resumeCursor: { threadId: "thread_1" }, | |
| 25 | 28 | createdAt: "2026-02-10T00:00:00.000Z", |
| 26 | 29 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 27 | 30 | }, |
| ... | ... | @@ -58,6 +61,7 @@ function createThreadControlHarness() { |
| 58 | 61 | provider: "codex", |
| 59 | 62 | status: "ready", |
| 60 | 63 | threadId: "thread_1", |
| 64 | resumeCursor: { threadId: "thread_1" }, | |
| 61 | 65 | createdAt: "2026-02-10T00:00:00.000Z", |
| 62 | 66 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 63 | 67 | }, |
| ... | ... | @@ -80,6 +84,46 @@ function createThreadControlHarness() { |
| 80 | 84 | return { manager, context, requireSession, sendRequest, updateSession }; |
| 81 | 85 | } |
| 82 | 86 | |
| 87 | function createPendingUserInputHarness() { | |
| 88 | const manager = new CodexAppServerManager(); | |
| 89 | const context = { | |
| 90 | session: { | |
| 91 | sessionId: "sess_1", | |
| 92 | provider: "codex", | |
| 93 | status: "ready", | |
| 94 | threadId: "thread_1", | |
| 95 | resumeCursor: { threadId: "thread_1" }, | |
| 96 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 97 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 98 | }, | |
| 99 | pendingUserInputs: new Map([ | |
| 100 | [ | |
| 101 | ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 102 | { | |
| 103 | requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 104 | jsonRpcId: 42, | |
| 105 | threadId: asThreadId("thread_1"), | |
| 106 | }, | |
| 107 | ], | |
| 108 | ]), | |
| 109 | }; | |
| 110 | ||
| 111 | const requireSession = vi | |
| 112 | .spyOn( | |
| 113 | manager as unknown as { requireSession: (sessionId: string) => unknown }, | |
| 114 | "requireSession", | |
| 115 | ) | |
| 116 | .mockReturnValue(context); | |
| 117 | const writeMessage = vi | |
| 118 | .spyOn(manager as unknown as { writeMessage: (...args: unknown[]) => void }, "writeMessage") | |
| 119 | .mockImplementation(() => {}); | |
| 120 | const emitEvent = vi | |
| 121 | .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") | |
| 122 | .mockImplementation(() => {}); | |
| 123 | ||
| 124 | return { manager, context, requireSession, writeMessage, emitEvent }; | |
| 125 | } | |
| 126 | ||
| 83 | 127 | describe("classifyCodexStderrLine", () => { |
| 84 | 128 | it("ignores empty lines", () => { |
| 85 | 129 | expect(classifyCodexStderrLine(" ")).toBeNull(); |
| ... | ... | @@ -151,6 +195,19 @@ describe("isRecoverableThreadResumeError", () => { |
| 151 | 195 | }); |
| 152 | 196 | |
| 153 | 197 | describe("startSession", () => { |
| 198 | it("enables Codex experimental api capabilities during initialize", () => { | |
| 199 | expect(buildCodexInitializeParams()).toEqual({ | |
| 200 | clientInfo: { | |
| 201 | name: "t3code_desktop", | |
| 202 | title: "T3 Code Desktop", | |
| 203 | version: "0.1.0", | |
| 204 | }, | |
| 205 | capabilities: { | |
| 206 | experimentalApi: true, | |
| 207 | }, | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 154 | 211 | it("emits session/startFailed when resolving cwd throws before process launch", async () => { |
| 155 | 212 | const manager = new CodexAppServerManager(); |
| 156 | 213 | const events: Array<{ method: string; kind: string; message?: string }> = []; |
| ... | ... | @@ -207,6 +264,7 @@ describe("sendTurn", () => { |
| 207 | 264 | expect(result).toEqual({ |
| 208 | 265 | threadId: "thread_1", |
| 209 | 266 | turnId: "turn_1", |
| 267 | resumeCursor: { threadId: "thread_1" }, | |
| 210 | 268 | }); |
| 211 | 269 | expect(requireSession).toHaveBeenCalledWith("thread_1"); |
| 212 | 270 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { |
| ... | ... | @@ -228,6 +286,7 @@ describe("sendTurn", () => { |
| 228 | 286 | expect(updateSession).toHaveBeenCalledWith(context, { |
| 229 | 287 | status: "running", |
| 230 | 288 | activeTurnId: "turn_1", |
| 289 | resumeCursor: { threadId: "thread_1" }, | |
| 231 | 290 | }); |
| 232 | 291 | }); |
| 233 | 292 | |
| ... | ... | @@ -255,6 +314,36 @@ describe("sendTurn", () => { |
| 255 | 314 | }); |
| 256 | 315 | }); |
| 257 | 316 | |
| 317 | it("passes Codex plan mode as a collaboration preset on turn/start", async () => { | |
| 318 | const { manager, context, sendRequest } = createSendTurnHarness(); | |
| 319 | ||
| 320 | await manager.sendTurn({ | |
| 321 | threadId: asThreadId("thread_1"), | |
| 322 | input: "Plan the work", | |
| 323 | interactionMode: "plan", | |
| 324 | }); | |
| 325 | ||
| 326 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { | |
| 327 | threadId: "thread_1", | |
| 328 | input: [ | |
| 329 | { | |
| 330 | type: "text", | |
| 331 | text: "Plan the work", | |
| 332 | text_elements: [], | |
| 333 | }, | |
| 334 | ], | |
| 335 | model: "gpt-5.3-codex", | |
| 336 | collaborationMode: { | |
| 337 | mode: "plan", | |
| 338 | settings: { | |
| 339 | model: "gpt-5.3-codex", | |
| 340 | reasoning_effort: "medium", | |
| 341 | developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, | |
| 342 | }, | |
| 343 | }, | |
| 344 | }); | |
| 345 | }); | |
| 346 | ||
| 258 | 347 | it("rejects empty turn input", async () => { |
| 259 | 348 | const { manager } = createSendTurnHarness(); |
| 260 | 349 | |
| ... | ... | @@ -354,6 +443,45 @@ describe("thread checkpoint control", () => { |
| 354 | 443 | }); |
| 355 | 444 | }); |
| 356 | 445 | |
| 446 | describe("respondToUserInput", () => { | |
| 447 | it("serializes canonical answers to Codex native answer objects", async () => { | |
| 448 | const { manager, context, requireSession, writeMessage, emitEvent } = | |
| 449 | createPendingUserInputHarness(); | |
| 450 | ||
| 451 | await manager.respondToUserInput( | |
| 452 | asThreadId("thread_1"), | |
| 453 | ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 454 | { | |
| 455 | scope: "All request methods", | |
| 456 | compat: "Keep current envelope", | |
| 457 | }, | |
| 458 | ); | |
| 459 | ||
| 460 | expect(requireSession).toHaveBeenCalledWith("thread_1"); | |
| 461 | expect(writeMessage).toHaveBeenCalledWith(context, { | |
| 462 | id: 42, | |
| 463 | result: { | |
| 464 | answers: { | |
| 465 | scope: { answers: ["All request methods"] }, | |
| 466 | compat: { answers: ["Keep current envelope"] }, | |
| 467 | }, | |
| 468 | }, | |
| 469 | }); | |
| 470 | expect(emitEvent).toHaveBeenCalledWith( | |
| 471 | expect.objectContaining({ | |
| 472 | method: "item/tool/requestUserInput/answered", | |
| 473 | payload: { | |
| 474 | requestId: "req-user-input-1", | |
| 475 | answers: { | |
| 476 | scope: { answers: ["All request methods"] }, | |
| 477 | compat: { answers: ["Keep current envelope"] }, | |
| 478 | }, | |
| 479 | }, | |
| 480 | }), | |
| 481 | ); | |
| 482 | }); | |
| 483 | }); | |
| 484 | ||
| 357 | 485 | describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { |
| 358 | 486 | it( |
| 359 | 487 | "keeps prior thread history when resuming with a changed runtime mode", |
apps/server/src/codexAppServerManager.ts+281-15| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | EventId, |
| 9 | 9 | ProviderItemId, |
| 10 | 10 | ProviderRequestKind, |
| 11 | type ProviderUserInputAnswers, | |
| 11 | 12 | ThreadId, |
| 12 | 13 | TurnId, |
| 13 | 14 | normalizeModelSlug, |
| ... | ... | @@ -38,12 +39,25 @@ interface PendingApprovalRequest { |
| 38 | 39 | itemId?: ProviderItemId; |
| 39 | 40 | } |
| 40 | 41 | |
| 42 | interface PendingUserInputRequest { | |
| 43 | requestId: ApprovalRequestId; | |
| 44 | jsonRpcId: string | number; | |
| 45 | threadId: ThreadId; | |
| 46 | turnId?: TurnId; | |
| 47 | itemId?: ProviderItemId; | |
| 48 | } | |
| 49 | ||
| 50 | interface CodexUserInputAnswer { | |
| 51 | answers: string[]; | |
| 52 | } | |
| 53 | ||
| 41 | 54 | interface CodexSessionContext { |
| 42 | 55 | session: ProviderSession; |
| 43 | 56 | child: ChildProcessWithoutNullStreams; |
| 44 | 57 | output: readline.Interface; |
| 45 | 58 | pending: Map<PendingRequestKey, PendingRequest>; |
| 46 | 59 | pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>; |
| 60 | pendingUserInputs: Map<ApprovalRequestId, PendingUserInputRequest>; | |
| 47 | 61 | nextRequestId: number; |
| 48 | 62 | stopping: boolean; |
| 49 | 63 | } |
| ... | ... | @@ -76,6 +90,7 @@ export interface CodexAppServerSendTurnInput { |
| 76 | 90 | readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; |
| 77 | 91 | readonly model?: string; |
| 78 | 92 | readonly effort?: string; |
| 93 | readonly interactionMode?: "default" | "plan"; | |
| 79 | 94 | } |
| 80 | 95 | |
| 81 | 96 | export interface CodexThreadTurnSnapshot { |
| ... | ... | @@ -103,6 +118,127 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ |
| 103 | 118 | "unknown thread", |
| 104 | 119 | "does not exist", |
| 105 | 120 | ]; |
| 121 | export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) | |
| 122 | ||
| 123 | 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. | |
| 124 | ||
| 125 | ## Mode rules (strict) | |
| 126 | ||
| 127 | You are in **Plan Mode** until a developer message explicitly ends it. | |
| 128 | ||
| 129 | 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. | |
| 130 | ||
| 131 | ## Plan Mode vs update_plan tool | |
| 132 | ||
| 133 | Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`<proposed_plan>\` block. | |
| 134 | ||
| 135 | 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. | |
| 136 | ||
| 137 | ## Execution vs. mutation in Plan Mode | |
| 138 | ||
| 139 | You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. | |
| 140 | ||
| 141 | ### Allowed (non-mutating, plan-improving) | |
| 142 | ||
| 143 | Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: | |
| 144 | ||
| 145 | * Reading or searching files, configs, schemas, types, manifests, and docs | |
| 146 | * Static analysis, inspection, and repo exploration | |
| 147 | * Dry-run style commands when they do not edit repo-tracked files | |
| 148 | * 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 | |
| 149 | ||
| 150 | ### Not allowed (mutating, plan-executing) | |
| 151 | ||
| 152 | Actions that implement the plan or change repo-tracked state. Examples: | |
| 153 | ||
| 154 | * Editing or writing files | |
| 155 | * Running formatters or linters that rewrite files | |
| 156 | * Applying patches, migrations, or codegen that updates repo-tracked files | |
| 157 | * Side-effectful commands whose purpose is to carry out the plan rather than refine it | |
| 158 | ||
| 159 | When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. | |
| 160 | ||
| 161 | ## PHASE 1 - Ground in the environment (explore first, ask second) | |
| 162 | ||
| 163 | 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. | |
| 164 | ||
| 165 | 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. | |
| 166 | ||
| 167 | 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. | |
| 168 | ||
| 169 | 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. | |
| 170 | ||
| 171 | ## PHASE 2 - Intent chat (what they actually want) | |
| 172 | ||
| 173 | * Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. | |
| 174 | * Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. | |
| 175 | ||
| 176 | ## PHASE 3 - Implementation chat (what/how we'll build) | |
| 177 | ||
| 178 | * 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. | |
| 179 | ||
| 180 | ## Asking questions | |
| 181 | ||
| 182 | Critical rules: | |
| 183 | ||
| 184 | * Strongly prefer using the \`request_user_input\` tool to ask any questions. | |
| 185 | * Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. | |
| 186 | * 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. | |
| 187 | ||
| 188 | You SHOULD ask many questions, but each question must: | |
| 189 | ||
| 190 | * materially change the spec/plan, OR | |
| 191 | * confirm/lock an assumption, OR | |
| 192 | * choose between meaningful tradeoffs. | |
| 193 | * not be answerable by non-mutating commands. | |
| 194 | ||
| 195 | 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. | |
| 196 | ||
| 197 | ## Two kinds of unknowns (treat differently) | |
| 198 | ||
| 199 | 1. **Discoverable facts** (repo/system truth): explore first. | |
| 200 | ||
| 201 | * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). | |
| 202 | * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. | |
| 203 | * If asking, present concrete candidates (paths/service names) + recommend one. | |
| 204 | * Never ask questions you can answer from your environment (e.g., "where is this struct"). | |
| 205 | ||
| 206 | 2. **Preferences/tradeoffs** (not discoverable): ask early. | |
| 207 | ||
| 208 | * These are intent or implementation preferences that cannot be derived from exploration. | |
| 209 | * Provide 2-4 mutually exclusive options + a recommended default. | |
| 210 | * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. | |
| 211 | ||
| 212 | ## Finalization rule | |
| 213 | ||
| 214 | Only output the final plan when it is decision complete and leaves no decisions to the implementer. | |
| 215 | ||
| 216 | When you present the official plan, wrap it in a \`<proposed_plan>\` block so the client can render it specially: | |
| 217 | ||
| 218 | 1) The opening tag must be on its own line. | |
| 219 | 2) Start the plan content on the next line (no text on the same line as the tag). | |
| 220 | 3) The closing tag must be on its own line. | |
| 221 | 4) Use Markdown inside the block. | |
| 222 | 5) Keep the tags exactly as \`<proposed_plan>\` and \`</proposed_plan>\` (do not translate or rename them), even if the plan content is in another language. | |
| 223 | ||
| 224 | Example: | |
| 225 | ||
| 226 | <proposed_plan> | |
| 227 | plan content | |
| 228 | </proposed_plan> | |
| 229 | ||
| 230 | plan content should be human and agent digestible. The final plan must be plan-only and include: | |
| 231 | ||
| 232 | * A clear title | |
| 233 | * A brief summary section | |
| 234 | * Important changes or additions to public APIs/interfaces/types | |
| 235 | * Test cases and scenarios | |
| 236 | * Explicit assumptions and defaults chosen where needed | |
| 237 | ||
| 238 | 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 \`<proposed_plan>\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. | |
| 239 | ||
| 240 | Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. | |
| 241 | </collaboration_mode>`; | |
| 106 | 242 | |
| 107 | 243 | function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): { |
| 108 | 244 | readonly approvalPolicy: "on-request" | "never"; |
| ... | ... | @@ -154,6 +290,80 @@ export function normalizeCodexModelSlug( |
| 154 | 290 | return normalized; |
| 155 | 291 | } |
| 156 | 292 | |
| 293 | export function buildCodexInitializeParams() { | |
| 294 | return { | |
| 295 | clientInfo: { | |
| 296 | name: "t3code_desktop", | |
| 297 | title: "T3 Code Desktop", | |
| 298 | version: "0.1.0", | |
| 299 | }, | |
| 300 | capabilities: { | |
| 301 | experimentalApi: true, | |
| 302 | }, | |
| 303 | } as const; | |
| 304 | } | |
| 305 | ||
| 306 | function buildCodexCollaborationMode(input: { | |
| 307 | readonly interactionMode?: "default" | "plan"; | |
| 308 | readonly model?: string; | |
| 309 | readonly effort?: string; | |
| 310 | }): | |
| 311 | | { | |
| 312 | mode: "plan"; | |
| 313 | settings: { | |
| 314 | model: string; | |
| 315 | reasoning_effort: string; | |
| 316 | developer_instructions: string; | |
| 317 | }; | |
| 318 | } | |
| 319 | | undefined { | |
| 320 | if (input.interactionMode !== "plan") { | |
| 321 | return undefined; | |
| 322 | } | |
| 323 | const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex"; | |
| 324 | return { | |
| 325 | mode: "plan", | |
| 326 | settings: { | |
| 327 | model, | |
| 328 | reasoning_effort: input.effort ?? "medium", | |
| 329 | developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, | |
| 330 | }, | |
| 331 | }; | |
| 332 | } | |
| 333 | ||
| 334 | function toCodexUserInputAnswer(value: unknown): CodexUserInputAnswer { | |
| 335 | if (typeof value === "string") { | |
| 336 | return { answers: [value] }; | |
| 337 | } | |
| 338 | ||
| 339 | if (Array.isArray(value)) { | |
| 340 | const answers = value.filter((entry): entry is string => typeof entry === "string"); | |
| 341 | if (answers.length > 0) { | |
| 342 | return { answers }; | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | if (value && typeof value === "object") { | |
| 347 | const maybeAnswers = (value as { answers?: unknown }).answers; | |
| 348 | if (Array.isArray(maybeAnswers)) { | |
| 349 | const answers = maybeAnswers.filter((entry): entry is string => typeof entry === "string"); | |
| 350 | if (answers.length > 0) { | |
| 351 | return { answers }; | |
| 352 | } | |
| 353 | } | |
| 354 | } | |
| 355 | ||
| 356 | throw new Error("User input answers must be strings or arrays of strings."); | |
| 357 | } | |
| 358 | ||
| 359 | function toCodexUserInputAnswers( | |
| 360 | answers: ProviderUserInputAnswers, | |
| 361 | ): Record<string, CodexUserInputAnswer> { | |
| 362 | return Object.fromEntries( | |
| 363 | Object.entries(answers).map(([questionId, value]) => [questionId, toCodexUserInputAnswer(value)]), | |
| 364 | ); | |
| 365 | } | |
| 366 | ||
| 157 | 367 | export function classifyCodexStderrLine(rawLine: string): { message: string } | null { |
| 158 | 368 | const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); |
| 159 | 369 | if (!line) { |
| ... | ... | @@ -237,6 +447,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 237 | 447 | output, |
| 238 | 448 | pending: new Map(), |
| 239 | 449 | pendingApprovals: new Map(), |
| 450 | pendingUserInputs: new Map(), | |
| 240 | 451 | nextRequestId: 1, |
| 241 | 452 | stopping: false, |
| 242 | 453 | }; |
| ... | ... | @@ -246,16 +457,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 246 | 457 | |
| 247 | 458 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); |
| 248 | 459 | |
| 249 | await this.sendRequest(context, "initialize", { | |
| 250 | clientInfo: { | |
| 251 | name: "t3code_desktop", | |
| 252 | title: "T3 Code Desktop", | |
| 253 | version: "0.1.0", | |
| 254 | }, | |
| 255 | capabilities: { | |
| 256 | experimentalApi: false, | |
| 257 | }, | |
| 258 | }); | |
| 460 | await this.sendRequest(context, "initialize", buildCodexInitializeParams()); | |
| 259 | 461 | |
| 260 | 462 | this.writeMessage(context, { method: "initialized" }); |
| 261 | 463 | |
| ... | ... | @@ -423,17 +625,36 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 423 | 625 | >; |
| 424 | 626 | model?: string; |
| 425 | 627 | effort?: string; |
| 628 | collaborationMode?: { | |
| 629 | mode: "plan"; | |
| 630 | settings: { | |
| 631 | model: string; | |
| 632 | reasoning_effort: string; | |
| 633 | developer_instructions: string; | |
| 634 | }; | |
| 635 | }; | |
| 426 | 636 | } = { |
| 427 | 637 | threadId: providerThreadId, |
| 428 | 638 | input: turnInput, |
| 429 | 639 | }; |
| 430 | const normalizedModel = normalizeCodexModelSlug(input.model); | |
| 640 | const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model); | |
| 431 | 641 | if (normalizedModel) { |
| 432 | 642 | turnStartParams.model = normalizedModel; |
| 433 | 643 | } |
| 434 | 644 | if (input.effort) { |
| 435 | 645 | turnStartParams.effort = input.effort; |
| 436 | 646 | } |
| 647 | const collaborationMode = buildCodexCollaborationMode({ | |
| 648 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), | |
| 649 | ...(normalizedModel !== undefined ? { model: normalizedModel } : {}), | |
| 650 | ...(input.effort !== undefined ? { effort: input.effort } : {}), | |
| 651 | }); | |
| 652 | if (collaborationMode) { | |
| 653 | if (!turnStartParams.model) { | |
| 654 | turnStartParams.model = collaborationMode.settings.model; | |
| 655 | } | |
| 656 | turnStartParams.collaborationMode = collaborationMode; | |
| 657 | } | |
| 437 | 658 | |
| 438 | 659 | const response = await this.sendRequest(context, "turn/start", turnStartParams); |
| 439 | 660 | |
| ... | ... | @@ -561,6 +782,43 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 561 | 782 | }); |
| 562 | 783 | } |
| 563 | 784 | |
| 785 | async respondToUserInput( | |
| 786 | threadId: ThreadId, | |
| 787 | requestId: ApprovalRequestId, | |
| 788 | answers: ProviderUserInputAnswers, | |
| 789 | ): Promise<void> { | |
| 790 | const context = this.requireSession(threadId); | |
| 791 | const pendingRequest = context.pendingUserInputs.get(requestId); | |
| 792 | if (!pendingRequest) { | |
| 793 | throw new Error(`Unknown pending user input request: ${requestId}`); | |
| 794 | } | |
| 795 | ||
| 796 | context.pendingUserInputs.delete(requestId); | |
| 797 | const codexAnswers = toCodexUserInputAnswers(answers); | |
| 798 | this.writeMessage(context, { | |
| 799 | id: pendingRequest.jsonRpcId, | |
| 800 | result: { | |
| 801 | answers: codexAnswers, | |
| 802 | }, | |
| 803 | }); | |
| 804 | ||
| 805 | this.emitEvent({ | |
| 806 | id: EventId.makeUnsafe(randomUUID()), | |
| 807 | kind: "notification", | |
| 808 | provider: "codex", | |
| 809 | threadId: context.session.threadId, | |
| 810 | createdAt: new Date().toISOString(), | |
| 811 | method: "item/tool/requestUserInput/answered", | |
| 812 | turnId: pendingRequest.turnId, | |
| 813 | itemId: pendingRequest.itemId, | |
| 814 | requestId: pendingRequest.requestId, | |
| 815 | payload: { | |
| 816 | requestId: pendingRequest.requestId, | |
| 817 | answers: codexAnswers, | |
| 818 | }, | |
| 819 | }); | |
| 820 | } | |
| 821 | ||
| 564 | 822 | stopSession(threadId: ThreadId): void { |
| 565 | 823 | const context = this.sessions.get(threadId); |
| 566 | 824 | if (!context) { |
| ... | ... | @@ -575,6 +833,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 575 | 833 | } |
| 576 | 834 | context.pending.clear(); |
| 577 | 835 | context.pendingApprovals.clear(); |
| 836 | context.pendingUserInputs.clear(); | |
| 578 | 837 | |
| 579 | 838 | context.output.close(); |
| 580 | 839 | |
| ... | ... | @@ -792,6 +1051,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 792 | 1051 | context.pendingApprovals.set(requestId, pendingRequest); |
| 793 | 1052 | } |
| 794 | 1053 | |
| 1054 | if (request.method === "item/tool/requestUserInput") { | |
| 1055 | requestId = ApprovalRequestId.makeUnsafe(randomUUID()); | |
| 1056 | context.pendingUserInputs.set(requestId, { | |
| 1057 | requestId, | |
| 1058 | jsonRpcId: request.id, | |
| 1059 | threadId: context.session.threadId, | |
| 1060 | ...(route.turnId ? { turnId: route.turnId } : {}), | |
| 1061 | ...(route.itemId ? { itemId: route.itemId } : {}), | |
| 1062 | }); | |
| 1063 | } | |
| 1064 | ||
| 795 | 1065 | this.emitEvent({ |
| 796 | 1066 | id: EventId.makeUnsafe(randomUUID()), |
| 797 | 1067 | kind: "request", |
| ... | ... | @@ -811,10 +1081,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 811 | 1081 | } |
| 812 | 1082 | |
| 813 | 1083 | if (request.method === "item/tool/requestUserInput") { |
| 814 | this.writeMessage(context, { | |
| 815 | id: request.id, | |
| 816 | result: { answers: {} }, | |
| 817 | }); | |
| 818 | 1084 | return; |
| 819 | 1085 | } |
| 820 | 1086 |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+4| ... | ... | @@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process"; |
| 6 | 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | 8 | CommandId, |
| 9 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 9 | 10 | EventId, |
| 10 | 11 | MessageId, |
| 11 | 12 | ProjectId, |
| ... | ... | @@ -85,6 +86,7 @@ function createProviderServiceHarness( |
| 85 | 86 | sendTurn: () => unsupported(), |
| 86 | 87 | interruptTurn: () => unsupported(), |
| 87 | 88 | respondToRequest: () => unsupported(), |
| 89 | respondToUserInput: () => unsupported(), | |
| 88 | 90 | stopSession: () => unsupported(), |
| 89 | 91 | listSessions, |
| 90 | 92 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| ... | ... | @@ -286,6 +288,7 @@ describe("CheckpointReactor", () => { |
| 286 | 288 | projectId: asProjectId("project-1"), |
| 287 | 289 | title: "Thread", |
| 288 | 290 | model: "gpt-5-codex", |
| 291 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 289 | 292 | runtimeMode: "approval-required", |
| 290 | 293 | branch: null, |
| 291 | 294 | worktreePath: options?.threadWorktreePath ?? cwd, |
| ... | ... | @@ -601,6 +604,7 @@ describe("CheckpointReactor", () => { |
| 601 | 604 | text: "start turn", |
| 602 | 605 | attachments: [], |
| 603 | 606 | }, |
| 607 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 604 | 608 | runtimeMode: "approval-required", |
| 605 | 609 | createdAt: new Date().toISOString(), |
| 606 | 610 | }), |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+17-3| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | CheckpointRef, |
| 3 | 3 | CommandId, |
| 4 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 4 | 5 | MessageId, |
| 5 | 6 | ProjectId, |
| 6 | 7 | ThreadId, |
| ... | ... | @@ -80,6 +81,7 @@ describe("OrchestrationEngine", () => { |
| 80 | 81 | projectId: asProjectId("project-1"), |
| 81 | 82 | title: "Thread", |
| 82 | 83 | model: "gpt-5-codex", |
| 84 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 83 | 85 | runtimeMode: "approval-required", |
| 84 | 86 | branch: null, |
| 85 | 87 | worktreePath: null, |
| ... | ... | @@ -97,6 +99,7 @@ describe("OrchestrationEngine", () => { |
| 97 | 99 | text: "hello", |
| 98 | 100 | attachments: [], |
| 99 | 101 | }, |
| 102 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 100 | 103 | runtimeMode: "approval-required", |
| 101 | 104 | createdAt, |
| 102 | 105 | }), |
| ... | ... | @@ -132,6 +135,7 @@ describe("OrchestrationEngine", () => { |
| 132 | 135 | projectId: asProjectId("project-replay"), |
| 133 | 136 | title: "replay", |
| 134 | 137 | model: "gpt-5-codex", |
| 138 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 135 | 139 | runtimeMode: "approval-required", |
| 136 | 140 | branch: null, |
| 137 | 141 | worktreePath: null, |
| ... | ... | @@ -193,7 +197,8 @@ describe("OrchestrationEngine", () => { |
| 193 | 197 | projectId: asProjectId("project-stream"), |
| 194 | 198 | title: "domain-stream", |
| 195 | 199 | model: "gpt-5-codex", |
| 196 | runtimeMode: "approval-required", | |
| 200 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 201 | runtimeMode: "approval-required", | |
| 197 | 202 | branch: null, |
| 198 | 203 | worktreePath: null, |
| 199 | 204 | createdAt, |
| ... | ... | @@ -237,6 +242,7 @@ describe("OrchestrationEngine", () => { |
| 237 | 242 | projectId: asProjectId("project-turn-diff"), |
| 238 | 243 | title: "Turn diff thread", |
| 239 | 244 | model: "gpt-5-codex", |
| 245 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 240 | 246 | runtimeMode: "approval-required", |
| 241 | 247 | branch: null, |
| 242 | 248 | worktreePath: null, |
| ... | ... | @@ -345,7 +351,8 @@ describe("OrchestrationEngine", () => { |
| 345 | 351 | projectId: asProjectId("project-flaky"), |
| 346 | 352 | title: "flaky-fail", |
| 347 | 353 | model: "gpt-5-codex", |
| 348 | runtimeMode: "approval-required", | |
| 354 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 355 | runtimeMode: "approval-required", | |
| 349 | 356 | branch: null, |
| 350 | 357 | worktreePath: null, |
| 351 | 358 | createdAt, |
| ... | ... | @@ -361,6 +368,7 @@ describe("OrchestrationEngine", () => { |
| 361 | 368 | projectId: asProjectId("project-flaky"), |
| 362 | 369 | title: "flaky-ok", |
| 363 | 370 | model: "gpt-5-codex", |
| 371 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 364 | 372 | runtimeMode: "approval-required", |
| 365 | 373 | branch: null, |
| 366 | 374 | worktreePath: null, |
| ... | ... | @@ -425,6 +433,7 @@ describe("OrchestrationEngine", () => { |
| 425 | 433 | projectId: asProjectId("project-atomic"), |
| 426 | 434 | title: "atomic", |
| 427 | 435 | model: "gpt-5-codex", |
| 436 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 428 | 437 | runtimeMode: "approval-required", |
| 429 | 438 | branch: null, |
| 430 | 439 | worktreePath: null, |
| ... | ... | @@ -442,6 +451,7 @@ describe("OrchestrationEngine", () => { |
| 442 | 451 | text: "hello", |
| 443 | 452 | attachments: [], |
| 444 | 453 | }, |
| 454 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 445 | 455 | runtimeMode: "approval-required" as const, |
| 446 | 456 | createdAt, |
| 447 | 457 | }; |
| ... | ... | @@ -558,6 +568,7 @@ describe("OrchestrationEngine", () => { |
| 558 | 568 | projectId: asProjectId("project-sync"), |
| 559 | 569 | title: "sync-before", |
| 560 | 570 | model: "gpt-5-codex", |
| 571 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 561 | 572 | runtimeMode: "approval-required", |
| 562 | 573 | branch: null, |
| 563 | 574 | worktreePath: null, |
| ... | ... | @@ -602,6 +613,7 @@ describe("OrchestrationEngine", () => { |
| 602 | 613 | text: "hello", |
| 603 | 614 | attachments: [], |
| 604 | 615 | }, |
| 616 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 605 | 617 | runtimeMode: "approval-required", |
| 606 | 618 | createdAt: now(), |
| 607 | 619 | }), |
| ... | ... | @@ -636,6 +648,7 @@ describe("OrchestrationEngine", () => { |
| 636 | 648 | projectId: asProjectId("project-duplicate"), |
| 637 | 649 | title: "duplicate", |
| 638 | 650 | model: "gpt-5-codex", |
| 651 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 639 | 652 | runtimeMode: "approval-required", |
| 640 | 653 | branch: null, |
| 641 | 654 | worktreePath: null, |
| ... | ... | @@ -652,7 +665,8 @@ describe("OrchestrationEngine", () => { |
| 652 | 665 | projectId: asProjectId("project-duplicate"), |
| 653 | 666 | title: "duplicate", |
| 654 | 667 | model: "gpt-5-codex", |
| 655 | runtimeMode: "approval-required", | |
| 668 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 669 | runtimeMode: "approval-required", | |
| 656 | 670 | branch: null, |
| 657 | 671 | worktreePath: null, |
| 658 | 672 | createdAt, |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16| ... | ... | @@ -397,6 +397,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 397 | 397 | title: event.payload.title, |
| 398 | 398 | model: event.payload.model, |
| 399 | 399 | runtimeMode: event.payload.runtimeMode, |
| 400 | interactionMode: event.payload.interactionMode, | |
| 400 | 401 | branch: event.payload.branch, |
| 401 | 402 | worktreePath: event.payload.worktreePath, |
| 402 | 403 | latestTurnId: null, |
| ... | ... | @@ -441,6 +442,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 441 | 442 | return; |
| 442 | 443 | } |
| 443 | 444 | |
| 445 | case "thread.interaction-mode-set": { | |
| 446 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 447 | threadId: event.payload.threadId, | |
| 448 | }); | |
| 449 | if (Option.isNone(existingRow)) { | |
| 450 | return; | |
| 451 | } | |
| 452 | yield* projectionThreadRepository.upsert({ | |
| 453 | ...existingRow.value, | |
| 454 | interactionMode: event.payload.interactionMode, | |
| 455 | updatedAt: event.payload.updatedAt, | |
| 456 | }); | |
| 457 | return; | |
| 458 | } | |
| 459 | ||
| 444 | 460 | case "thread.deleted": { |
| 445 | 461 | attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); |
| 446 | 462 | const existingRow = yield* projectionThreadRepository.getById({ |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1| ... | ... | @@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 234 | 234 | projectId: asProjectId("project-1"), |
| 235 | 235 | title: "Thread 1", |
| 236 | 236 | model: "gpt-5-codex", |
| 237 | interactionMode: "default", | |
| 237 | 238 | runtimeMode: "full-access", |
| 238 | 239 | branch: null, |
| 239 | 240 | worktreePath: null, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2| ... | ... | @@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 154 | 154 | title, |
| 155 | 155 | model, |
| 156 | 156 | runtime_mode AS "runtimeMode", |
| 157 | interaction_mode AS "interactionMode", | |
| 157 | 158 | branch, |
| 158 | 159 | worktree_path AS "worktreePath", |
| 159 | 160 | latest_turn_id AS "latestTurnId", |
| ... | ... | @@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 485 | 486 | title: row.title, |
| 486 | 487 | model: row.model, |
| 487 | 488 | runtimeMode: row.runtimeMode, |
| 489 | interactionMode: row.interactionMode, | |
| 488 | 490 | branch: row.branch, |
| 489 | 491 | worktreePath: row.worktreePath, |
| 490 | 492 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+102-1| ... | ... | @@ -6,6 +6,7 @@ import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | 7 | ApprovalRequestId, |
| 8 | 8 | CommandId, |
| 9 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 9 | 10 | EventId, |
| 10 | 11 | MessageId, |
| 11 | 12 | ProjectId, |
| ... | ... | @@ -139,6 +140,7 @@ describe("ProviderCommandReactor", () => { |
| 139 | 140 | ); |
| 140 | 141 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 141 | 142 | const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void); |
| 143 | const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void); | |
| 142 | 144 | const stopSession = vi.fn((input: unknown) => |
| 143 | 145 | Effect.sync(() => { |
| 144 | 146 | const threadId = |
| ... | ... | @@ -180,6 +182,7 @@ describe("ProviderCommandReactor", () => { |
| 180 | 182 | sendTurn: sendTurn as ProviderServiceShape["sendTurn"], |
| 181 | 183 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], |
| 182 | 184 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], |
| 185 | respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], | |
| 183 | 186 | stopSession: stopSession as ProviderServiceShape["stopSession"], |
| 184 | 187 | listSessions: () => Effect.succeed(runtimeSessions), |
| 185 | 188 | getCapabilities: (provider) => |
| ... | ... | @@ -234,6 +237,7 @@ describe("ProviderCommandReactor", () => { |
| 234 | 237 | projectId: asProjectId("project-1"), |
| 235 | 238 | title: "Thread", |
| 236 | 239 | model: "gpt-5-codex", |
| 240 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 237 | 241 | runtimeMode: "approval-required", |
| 238 | 242 | branch: null, |
| 239 | 243 | worktreePath: null, |
| ... | ... | @@ -247,6 +251,7 @@ describe("ProviderCommandReactor", () => { |
| 247 | 251 | sendTurn, |
| 248 | 252 | interruptTurn, |
| 249 | 253 | respondToRequest, |
| 254 | respondToUserInput, | |
| 250 | 255 | stopSession, |
| 251 | 256 | renameBranch, |
| 252 | 257 | generateBranchName, |
| ... | ... | @@ -269,6 +274,7 @@ describe("ProviderCommandReactor", () => { |
| 269 | 274 | text: "hello reactor", |
| 270 | 275 | attachments: [], |
| 271 | 276 | }, |
| 277 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 272 | 278 | runtimeMode: "approval-required", |
| 273 | 279 | createdAt: now, |
| 274 | 280 | }), |
| ... | ... | @@ -289,6 +295,44 @@ describe("ProviderCommandReactor", () => { |
| 289 | 295 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 290 | 296 | }); |
| 291 | 297 | |
| 298 | it("forwards plan interaction mode to the provider turn request", async () => { | |
| 299 | const harness = await createHarness(); | |
| 300 | const now = new Date().toISOString(); | |
| 301 | ||
| 302 | await Effect.runPromise( | |
| 303 | harness.engine.dispatch({ | |
| 304 | type: "thread.interaction-mode.set", | |
| 305 | commandId: CommandId.makeUnsafe("cmd-interaction-mode-set-plan"), | |
| 306 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 307 | interactionMode: "plan", | |
| 308 | createdAt: now, | |
| 309 | }), | |
| 310 | ); | |
| 311 | ||
| 312 | await Effect.runPromise( | |
| 313 | harness.engine.dispatch({ | |
| 314 | type: "thread.turn.start", | |
| 315 | commandId: CommandId.makeUnsafe("cmd-turn-start-plan"), | |
| 316 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 317 | message: { | |
| 318 | messageId: asMessageId("user-message-plan"), | |
| 319 | role: "user", | |
| 320 | text: "plan this change", | |
| 321 | attachments: [], | |
| 322 | }, | |
| 323 | interactionMode: "plan", | |
| 324 | runtimeMode: "approval-required", | |
| 325 | createdAt: now, | |
| 326 | }), | |
| 327 | ); | |
| 328 | ||
| 329 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 330 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | |
| 331 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 332 | interactionMode: "plan", | |
| 333 | }); | |
| 334 | }); | |
| 335 | ||
| 292 | 336 | it("starts first turn with requested provider when provider is specified", async () => { |
| 293 | 337 | const harness = await createHarness(); |
| 294 | 338 | const now = new Date().toISOString(); |
| ... | ... | @@ -305,6 +349,7 @@ describe("ProviderCommandReactor", () => { |
| 305 | 349 | attachments: [], |
| 306 | 350 | }, |
| 307 | 351 | provider: "claudeCode", |
| 352 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 308 | 353 | runtimeMode: "approval-required", |
| 309 | 354 | createdAt: now, |
| 310 | 355 | }), |
| ... | ... | @@ -341,6 +386,7 @@ describe("ProviderCommandReactor", () => { |
| 341 | 386 | attachments: [], |
| 342 | 387 | }, |
| 343 | 388 | provider: "cursor", |
| 389 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 344 | 390 | runtimeMode: "approval-required", |
| 345 | 391 | createdAt: now, |
| 346 | 392 | }), |
| ... | ... | @@ -376,6 +422,7 @@ describe("ProviderCommandReactor", () => { |
| 376 | 422 | text: "first", |
| 377 | 423 | attachments: [], |
| 378 | 424 | }, |
| 425 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 379 | 426 | runtimeMode: "approval-required", |
| 380 | 427 | createdAt: now, |
| 381 | 428 | }), |
| ... | ... | @@ -395,6 +442,7 @@ describe("ProviderCommandReactor", () => { |
| 395 | 442 | text: "second", |
| 396 | 443 | attachments: [], |
| 397 | 444 | }, |
| 445 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 398 | 446 | runtimeMode: "approval-required", |
| 399 | 447 | createdAt: now, |
| 400 | 448 | }), |
| ... | ... | @@ -422,6 +470,7 @@ describe("ProviderCommandReactor", () => { |
| 422 | 470 | }, |
| 423 | 471 | provider: "cursor", |
| 424 | 472 | model: "composer-1.5", |
| 473 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 425 | 474 | runtimeMode: "approval-required", |
| 426 | 475 | createdAt: now, |
| 427 | 476 | }), |
| ... | ... | @@ -443,6 +492,7 @@ describe("ProviderCommandReactor", () => { |
| 443 | 492 | }, |
| 444 | 493 | provider: "cursor", |
| 445 | 494 | model: "composer-1.5", |
| 495 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 446 | 496 | runtimeMode: "approval-required", |
| 447 | 497 | createdAt: now, |
| 448 | 498 | }), |
| ... | ... | @@ -470,6 +520,7 @@ describe("ProviderCommandReactor", () => { |
| 470 | 520 | }, |
| 471 | 521 | provider: "cursor", |
| 472 | 522 | model: "gpt-5.3-codex", |
| 523 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 473 | 524 | runtimeMode: "approval-required", |
| 474 | 525 | createdAt: now, |
| 475 | 526 | }), |
| ... | ... | @@ -491,6 +542,7 @@ describe("ProviderCommandReactor", () => { |
| 491 | 542 | }, |
| 492 | 543 | provider: "cursor", |
| 493 | 544 | model: "composer-1.5", |
| 545 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 494 | 546 | runtimeMode: "approval-required", |
| 495 | 547 | createdAt: now, |
| 496 | 548 | }), |
| ... | ... | @@ -531,6 +583,7 @@ describe("ProviderCommandReactor", () => { |
| 531 | 583 | text: "first", |
| 532 | 584 | attachments: [], |
| 533 | 585 | }, |
| 586 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 534 | 587 | runtimeMode: "full-access", |
| 535 | 588 | createdAt: now, |
| 536 | 589 | }), |
| ... | ... | @@ -566,6 +619,7 @@ describe("ProviderCommandReactor", () => { |
| 566 | 619 | text: "second", |
| 567 | 620 | attachments: [], |
| 568 | 621 | }, |
| 622 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 569 | 623 | runtimeMode: "full-access", |
| 570 | 624 | createdAt: now, |
| 571 | 625 | }), |
| ... | ... | @@ -604,6 +658,7 @@ describe("ProviderCommandReactor", () => { |
| 604 | 658 | text: "first", |
| 605 | 659 | attachments: [], |
| 606 | 660 | }, |
| 661 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 607 | 662 | runtimeMode: "approval-required", |
| 608 | 663 | createdAt: now, |
| 609 | 664 | }), |
| ... | ... | @@ -624,6 +679,7 @@ describe("ProviderCommandReactor", () => { |
| 624 | 679 | attachments: [], |
| 625 | 680 | }, |
| 626 | 681 | provider: "claudeCode", |
| 682 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 627 | 683 | runtimeMode: "approval-required", |
| 628 | 684 | createdAt: now, |
| 629 | 685 | }), |
| ... | ... | @@ -672,6 +728,7 @@ describe("ProviderCommandReactor", () => { |
| 672 | 728 | text: "first", |
| 673 | 729 | attachments: [], |
| 674 | 730 | }, |
| 731 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 675 | 732 | runtimeMode: "full-access", |
| 676 | 733 | createdAt: now, |
| 677 | 734 | }), |
| ... | ... | @@ -746,7 +803,6 @@ describe("ProviderCommandReactor", () => { |
| 746 | 803 | await waitFor(() => harness.interruptTurn.mock.calls.length === 1); |
| 747 | 804 | expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ |
| 748 | 805 | threadId: "thread-1", |
| 749 | turnId: "turn-1", | |
| 750 | 806 | }); |
| 751 | 807 | }); |
| 752 | 808 | |
| ... | ... | @@ -791,6 +847,51 @@ describe("ProviderCommandReactor", () => { |
| 791 | 847 | }); |
| 792 | 848 | }); |
| 793 | 849 | |
| 850 | it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { | |
| 851 | const harness = await createHarness(); | |
| 852 | const now = new Date().toISOString(); | |
| 853 | ||
| 854 | await Effect.runPromise( | |
| 855 | harness.engine.dispatch({ | |
| 856 | type: "thread.session.set", | |
| 857 | commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input"), | |
| 858 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 859 | session: { | |
| 860 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 861 | status: "running", | |
| 862 | providerName: "codex", | |
| 863 | runtimeMode: "approval-required", | |
| 864 | activeTurnId: null, | |
| 865 | lastError: null, | |
| 866 | updatedAt: now, | |
| 867 | }, | |
| 868 | createdAt: now, | |
| 869 | }), | |
| 870 | ); | |
| 871 | ||
| 872 | await Effect.runPromise( | |
| 873 | harness.engine.dispatch({ | |
| 874 | type: "thread.user-input.respond", | |
| 875 | commandId: CommandId.makeUnsafe("cmd-user-input-respond"), | |
| 876 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 877 | requestId: asApprovalRequestId("user-input-request-1"), | |
| 878 | answers: { | |
| 879 | sandbox_mode: "workspace-write", | |
| 880 | }, | |
| 881 | createdAt: now, | |
| 882 | }), | |
| 883 | ); | |
| 884 | ||
| 885 | await waitFor(() => harness.respondToUserInput.mock.calls.length === 1); | |
| 886 | expect(harness.respondToUserInput.mock.calls[0]?.[0]).toEqual({ | |
| 887 | threadId: "thread-1", | |
| 888 | requestId: "user-input-request-1", | |
| 889 | answers: { | |
| 890 | sandbox_mode: "workspace-write", | |
| 891 | }, | |
| 892 | }); | |
| 893 | }); | |
| 894 | ||
| 794 | 895 | it("surfaces stale provider approval request failures without faking approval resolution", async () => { |
| 795 | 896 | const harness = await createHarness(); |
| 796 | 897 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+53| ... | ... | @@ -31,6 +31,7 @@ type ProviderIntentEvent = Extract< |
| 31 | 31 | | "thread.turn-start-requested" |
| 32 | 32 | | "thread.turn-interrupt-requested" |
| 33 | 33 | | "thread.approval-response-requested" |
| 34 | | "thread.user-input-response-requested" | |
| 34 | 35 | | "thread.session-stop-requested"; |
| 35 | 36 | } |
| 36 | 37 | >; |
| ... | ... | @@ -147,6 +148,7 @@ const make = Effect.gen(function* () { |
| 147 | 148 | | "provider.turn.start.failed" |
| 148 | 149 | | "provider.turn.interrupt.failed" |
| 149 | 150 | | "provider.approval.respond.failed" |
| 151 | | "provider.user-input.respond.failed" | |
| 150 | 152 | | "provider.session.stop.failed"; |
| 151 | 153 | readonly summary: string; |
| 152 | 154 | readonly detail: string; |
| ... | ... | @@ -320,6 +322,7 @@ const make = Effect.gen(function* () { |
| 320 | 322 | readonly provider?: ProviderKind; |
| 321 | 323 | readonly model?: string; |
| 322 | 324 | readonly effort?: string; |
| 325 | readonly interactionMode?: "default" | "plan"; | |
| 323 | 326 | readonly createdAt: string; |
| 324 | 327 | }) { |
| 325 | 328 | const thread = yield* resolveThread(input.threadId); |
| ... | ... | @@ -348,6 +351,7 @@ const make = Effect.gen(function* () { |
| 348 | 351 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), |
| 349 | 352 | ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), |
| 350 | 353 | ...(input.effort !== undefined ? { effort: input.effort } : {}), |
| 354 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), | |
| 351 | 355 | }); |
| 352 | 356 | }); |
| 353 | 357 | |
| ... | ... | @@ -461,6 +465,7 @@ const make = Effect.gen(function* () { |
| 461 | 465 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), |
| 462 | 466 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), |
| 463 | 467 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), |
| 468 | interactionMode: event.payload.interactionMode, | |
| 464 | 469 | createdAt: event.payload.createdAt, |
| 465 | 470 | }); |
| 466 | 471 | }); |
| ... | ... | @@ -535,6 +540,50 @@ const make = Effect.gen(function* () { |
| 535 | 540 | ); |
| 536 | 541 | }); |
| 537 | 542 | |
| 543 | const processUserInputResponseRequested = Effect.fnUntraced(function* ( | |
| 544 | event: Extract<ProviderIntentEvent, { type: "thread.user-input-response-requested" }>, | |
| 545 | ) { | |
| 546 | const thread = yield* resolveThread(event.payload.threadId); | |
| 547 | if (!thread) { | |
| 548 | return; | |
| 549 | } | |
| 550 | const hasSession = thread.session && thread.session.status !== "stopped"; | |
| 551 | if (!hasSession) { | |
| 552 | return yield* appendProviderFailureActivity({ | |
| 553 | threadId: event.payload.threadId, | |
| 554 | kind: "provider.user-input.respond.failed", | |
| 555 | summary: "Provider user input response failed", | |
| 556 | detail: "No active provider session is bound to this thread.", | |
| 557 | turnId: null, | |
| 558 | createdAt: event.payload.createdAt, | |
| 559 | requestId: event.payload.requestId, | |
| 560 | }); | |
| 561 | } | |
| 562 | ||
| 563 | yield* providerService | |
| 564 | .respondToUserInput({ | |
| 565 | threadId: event.payload.threadId, | |
| 566 | requestId: event.payload.requestId, | |
| 567 | answers: event.payload.answers, | |
| 568 | }) | |
| 569 | .pipe( | |
| 570 | Effect.catchCause((cause) => | |
| 571 | Effect.gen(function* () { | |
| 572 | const error = Cause.squash(cause); | |
| 573 | yield* appendProviderFailureActivity({ | |
| 574 | threadId: event.payload.threadId, | |
| 575 | kind: "provider.user-input.respond.failed", | |
| 576 | summary: "Provider user input response failed", | |
| 577 | detail: toErrorMessage(error), | |
| 578 | turnId: null, | |
| 579 | createdAt: event.payload.createdAt, | |
| 580 | requestId: event.payload.requestId, | |
| 581 | }); | |
| 582 | }), | |
| 583 | ), | |
| 584 | ); | |
| 585 | }); | |
| 586 | ||
| 538 | 587 | const processSessionStopRequested = Effect.fnUntraced(function* ( |
| 539 | 588 | event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>, |
| 540 | 589 | ) { |
| ... | ... | @@ -583,6 +632,9 @@ const make = Effect.gen(function* () { |
| 583 | 632 | case "thread.approval-response-requested": |
| 584 | 633 | yield* processApprovalResponseRequested(event); |
| 585 | 634 | return; |
| 635 | case "thread.user-input-response-requested": | |
| 636 | yield* processUserInputResponseRequested(event); | |
| 637 | return; | |
| 586 | 638 | case "thread.session-stop-requested": |
| 587 | 639 | yield* processSessionStopRequested(event); |
| 588 | 640 | return; |
| ... | ... | @@ -617,6 +669,7 @@ const make = Effect.gen(function* () { |
| 617 | 669 | event.type !== "thread.turn-start-requested" && |
| 618 | 670 | event.type !== "thread.turn-interrupt-requested" && |
| 619 | 671 | event.type !== "thread.approval-response-requested" && |
| 672 | event.type !== "thread.user-input-response-requested" && | |
| 620 | 673 | event.type !== "thread.session-stop-requested" |
| 621 | 674 | ) { |
| 622 | 675 | return Effect.void; |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+264| ... | ... | @@ -2,6 +2,7 @@ import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/cont |
| 2 | 2 | import { |
| 3 | 3 | ApprovalRequestId, |
| 4 | 4 | CommandId, |
| 5 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 5 | 6 | EventId, |
| 6 | 7 | MessageId, |
| 7 | 8 | ProjectId, |
| ... | ... | @@ -59,6 +60,7 @@ function createProviderServiceHarness() { |
| 59 | 60 | sendTurn: () => unsupported(), |
| 60 | 61 | interruptTurn: () => unsupported(), |
| 61 | 62 | respondToRequest: () => unsupported(), |
| 63 | respondToUserInput: () => unsupported(), | |
| 62 | 64 | stopSession: () => unsupported(), |
| 63 | 65 | listSessions: () => Effect.succeed([]), |
| 64 | 66 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| ... | ... | @@ -163,6 +165,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 163 | 165 | projectId: asProjectId("project-1"), |
| 164 | 166 | title: "Thread", |
| 165 | 167 | model: "gpt-5-codex", |
| 168 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 166 | 169 | runtimeMode: "approval-required", |
| 167 | 170 | branch: null, |
| 168 | 171 | worktreePath: null, |
| ... | ... | @@ -630,6 +633,112 @@ describe("ProviderRuntimeIngestion", () => { |
| 630 | 633 | expect(message?.streaming).toBe(false); |
| 631 | 634 | }); |
| 632 | 635 | |
| 636 | it("projects completed plan items into finalized proposed-plan assistant messages", async () => { | |
| 637 | const harness = await createHarness(); | |
| 638 | const now = new Date().toISOString(); | |
| 639 | ||
| 640 | harness.emit({ | |
| 641 | type: "item.completed", | |
| 642 | eventId: asEventId("evt-plan-item-completed"), | |
| 643 | provider: "codex", | |
| 644 | createdAt: now, | |
| 645 | threadId: asThreadId("thread-1"), | |
| 646 | turnId: asTurnId("turn-plan-final"), | |
| 647 | itemId: asItemId("item-plan-final"), | |
| 648 | payload: { | |
| 649 | itemType: "plan", | |
| 650 | status: "completed", | |
| 651 | detail: "## Ship plan\n\n- wire projection\n- render follow-up", | |
| 652 | }, | |
| 653 | }); | |
| 654 | ||
| 655 | const thread = await waitForThread(harness.engine, (entry) => { | |
| 656 | const message = entry.messages.find( | |
| 657 | (candidate: ProviderRuntimeTestMessage) => candidate.id === "assistant:item-plan-final", | |
| 658 | ); | |
| 659 | return message?.streaming === false && message.text.includes("<proposed_plan>"); | |
| 660 | }); | |
| 661 | const message = thread.messages.find( | |
| 662 | (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-plan-final", | |
| 663 | ); | |
| 664 | expect(message?.text).toBe( | |
| 665 | "<proposed_plan>\n## Ship plan\n\n- wire projection\n- render follow-up\n</proposed_plan>", | |
| 666 | ); | |
| 667 | expect(message?.streaming).toBe(false); | |
| 668 | }); | |
| 669 | ||
| 670 | it("finalizes buffered plan deltas into a proposed-plan assistant message on turn completion", async () => { | |
| 671 | const harness = await createHarness(); | |
| 672 | const now = new Date().toISOString(); | |
| 673 | ||
| 674 | harness.emit({ | |
| 675 | type: "turn.started", | |
| 676 | eventId: asEventId("evt-turn-started-plan-buffer"), | |
| 677 | provider: "codex", | |
| 678 | createdAt: now, | |
| 679 | threadId: asThreadId("thread-1"), | |
| 680 | turnId: asTurnId("turn-plan-buffer"), | |
| 681 | }); | |
| 682 | ||
| 683 | await waitForThread( | |
| 684 | harness.engine, | |
| 685 | (thread) => | |
| 686 | thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer", | |
| 687 | ); | |
| 688 | ||
| 689 | harness.emit({ | |
| 690 | type: "content.delta", | |
| 691 | eventId: asEventId("evt-plan-delta-1"), | |
| 692 | provider: "codex", | |
| 693 | createdAt: now, | |
| 694 | threadId: asThreadId("thread-1"), | |
| 695 | turnId: asTurnId("turn-plan-buffer"), | |
| 696 | itemId: asItemId("item-plan-buffer"), | |
| 697 | payload: { | |
| 698 | streamKind: "plan_text", | |
| 699 | delta: "## Buffered plan\n\n- first", | |
| 700 | }, | |
| 701 | }); | |
| 702 | harness.emit({ | |
| 703 | type: "content.delta", | |
| 704 | eventId: asEventId("evt-plan-delta-2"), | |
| 705 | provider: "codex", | |
| 706 | createdAt: now, | |
| 707 | threadId: asThreadId("thread-1"), | |
| 708 | turnId: asTurnId("turn-plan-buffer"), | |
| 709 | itemId: asItemId("item-plan-buffer"), | |
| 710 | payload: { | |
| 711 | streamKind: "plan_text", | |
| 712 | delta: "\n- second", | |
| 713 | }, | |
| 714 | }); | |
| 715 | harness.emit({ | |
| 716 | type: "turn.completed", | |
| 717 | eventId: asEventId("evt-turn-completed-plan-buffer"), | |
| 718 | provider: "codex", | |
| 719 | createdAt: now, | |
| 720 | threadId: asThreadId("thread-1"), | |
| 721 | turnId: asTurnId("turn-plan-buffer"), | |
| 722 | payload: { | |
| 723 | state: "completed", | |
| 724 | }, | |
| 725 | }); | |
| 726 | ||
| 727 | const thread = await waitForThread(harness.engine, (entry) => { | |
| 728 | const message = entry.messages.find( | |
| 729 | (candidate: ProviderRuntimeTestMessage) => candidate.id === "assistant:item-plan-buffer", | |
| 730 | ); | |
| 731 | return message?.streaming === false && message.text.includes("<proposed_plan>"); | |
| 732 | }); | |
| 733 | const message = thread.messages.find( | |
| 734 | (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-plan-buffer", | |
| 735 | ); | |
| 736 | expect(message?.text).toBe( | |
| 737 | "<proposed_plan>\n## Buffered plan\n\n- first\n- second\n</proposed_plan>", | |
| 738 | ); | |
| 739 | expect(message?.streaming).toBe(false); | |
| 740 | }); | |
| 741 | ||
| 633 | 742 | it("buffers assistant deltas by default until completion", async () => { |
| 634 | 743 | const harness = await createHarness(); |
| 635 | 744 | const now = new Date().toISOString(); |
| ... | ... | @@ -716,6 +825,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 716 | 825 | attachments: [], |
| 717 | 826 | }, |
| 718 | 827 | assistantDeliveryMode: "streaming", |
| 828 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 719 | 829 | runtimeMode: "approval-required", |
| 720 | 830 | createdAt: now, |
| 721 | 831 | }), |
| ... | ... | @@ -1217,6 +1327,160 @@ describe("ProviderRuntimeIngestion", () => { |
| 1217 | 1327 | expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); |
| 1218 | 1328 | }); |
| 1219 | 1329 | |
| 1330 | it("projects Codex task lifecycle chunks into thread activities", async () => { | |
| 1331 | const harness = await createHarness(); | |
| 1332 | const now = new Date().toISOString(); | |
| 1333 | ||
| 1334 | harness.emit({ | |
| 1335 | type: "task.started", | |
| 1336 | eventId: asEventId("evt-task-started"), | |
| 1337 | provider: "codex", | |
| 1338 | createdAt: now, | |
| 1339 | threadId: asThreadId("thread-1"), | |
| 1340 | turnId: asTurnId("turn-task-1"), | |
| 1341 | payload: { | |
| 1342 | taskId: "turn-task-1", | |
| 1343 | taskType: "plan", | |
| 1344 | }, | |
| 1345 | }); | |
| 1346 | ||
| 1347 | harness.emit({ | |
| 1348 | type: "task.progress", | |
| 1349 | eventId: asEventId("evt-task-progress"), | |
| 1350 | provider: "codex", | |
| 1351 | createdAt: now, | |
| 1352 | threadId: asThreadId("thread-1"), | |
| 1353 | turnId: asTurnId("turn-task-1"), | |
| 1354 | payload: { | |
| 1355 | taskId: "turn-task-1", | |
| 1356 | description: "Comparing the desktop rollout chunks to the app-server stream.", | |
| 1357 | }, | |
| 1358 | }); | |
| 1359 | ||
| 1360 | harness.emit({ | |
| 1361 | type: "task.completed", | |
| 1362 | eventId: asEventId("evt-task-completed"), | |
| 1363 | provider: "codex", | |
| 1364 | createdAt: now, | |
| 1365 | threadId: asThreadId("thread-1"), | |
| 1366 | turnId: asTurnId("turn-task-1"), | |
| 1367 | payload: { | |
| 1368 | taskId: "turn-task-1", | |
| 1369 | status: "completed", | |
| 1370 | summary: "<proposed_plan>\n# Plan title\n</proposed_plan>", | |
| 1371 | }, | |
| 1372 | }); | |
| 1373 | ||
| 1374 | const thread = await waitForThread( | |
| 1375 | harness.engine, | |
| 1376 | (entry) => | |
| 1377 | entry.activities.some( | |
| 1378 | (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed", | |
| 1379 | ), | |
| 1380 | ); | |
| 1381 | ||
| 1382 | const started = thread.activities.find( | |
| 1383 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", | |
| 1384 | ); | |
| 1385 | const progress = thread.activities.find( | |
| 1386 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress", | |
| 1387 | ); | |
| 1388 | const completed = thread.activities.find( | |
| 1389 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", | |
| 1390 | ); | |
| 1391 | ||
| 1392 | const progressPayload = | |
| 1393 | progress?.payload && typeof progress.payload === "object" | |
| 1394 | ? (progress.payload as Record<string, unknown>) | |
| 1395 | : undefined; | |
| 1396 | const completedPayload = | |
| 1397 | completed?.payload && typeof completed.payload === "object" | |
| 1398 | ? (completed.payload as Record<string, unknown>) | |
| 1399 | : undefined; | |
| 1400 | ||
| 1401 | expect(started?.kind).toBe("task.started"); | |
| 1402 | expect(started?.summary).toBe("Plan task started"); | |
| 1403 | expect(progress?.kind).toBe("task.progress"); | |
| 1404 | expect(progressPayload?.detail).toBe( | |
| 1405 | "Comparing the desktop rollout chunks to the app-server stream.", | |
| 1406 | ); | |
| 1407 | expect(completed?.kind).toBe("task.completed"); | |
| 1408 | expect(completedPayload?.detail).toBe("<proposed_plan>\n# Plan title\n</proposed_plan>"); | |
| 1409 | }); | |
| 1410 | ||
| 1411 | it("projects structured user input request and resolution as thread activities", async () => { | |
| 1412 | const harness = await createHarness(); | |
| 1413 | const now = new Date().toISOString(); | |
| 1414 | ||
| 1415 | harness.emit({ | |
| 1416 | type: "user-input.requested", | |
| 1417 | eventId: asEventId("evt-user-input-requested"), | |
| 1418 | provider: "codex", | |
| 1419 | createdAt: now, | |
| 1420 | threadId: asThreadId("thread-1"), | |
| 1421 | turnId: asTurnId("turn-user-input"), | |
| 1422 | requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 1423 | payload: { | |
| 1424 | questions: [ | |
| 1425 | { | |
| 1426 | id: "sandbox_mode", | |
| 1427 | header: "Sandbox", | |
| 1428 | question: "Which mode should be used?", | |
| 1429 | options: [ | |
| 1430 | { | |
| 1431 | label: "workspace-write", | |
| 1432 | description: "Allow workspace writes only", | |
| 1433 | }, | |
| 1434 | ], | |
| 1435 | }, | |
| 1436 | ], | |
| 1437 | }, | |
| 1438 | }); | |
| 1439 | ||
| 1440 | harness.emit({ | |
| 1441 | type: "user-input.resolved", | |
| 1442 | eventId: asEventId("evt-user-input-resolved"), | |
| 1443 | provider: "codex", | |
| 1444 | createdAt: new Date().toISOString(), | |
| 1445 | threadId: asThreadId("thread-1"), | |
| 1446 | turnId: asTurnId("turn-user-input"), | |
| 1447 | requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 1448 | payload: { | |
| 1449 | answers: { | |
| 1450 | sandbox_mode: "workspace-write", | |
| 1451 | }, | |
| 1452 | }, | |
| 1453 | }); | |
| 1454 | ||
| 1455 | const thread = await waitForThread( | |
| 1456 | harness.engine, | |
| 1457 | (entry) => | |
| 1458 | entry.activities.some( | |
| 1459 | (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested", | |
| 1460 | ) && | |
| 1461 | entry.activities.some( | |
| 1462 | (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.resolved", | |
| 1463 | ), | |
| 1464 | ); | |
| 1465 | ||
| 1466 | const requested = thread.activities.find( | |
| 1467 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-requested", | |
| 1468 | ); | |
| 1469 | expect(requested?.kind).toBe("user-input.requested"); | |
| 1470 | ||
| 1471 | const resolved = thread.activities.find( | |
| 1472 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-resolved", | |
| 1473 | ); | |
| 1474 | const resolvedPayload = | |
| 1475 | resolved?.payload && typeof resolved.payload === "object" | |
| 1476 | ? (resolved.payload as Record<string, unknown>) | |
| 1477 | : undefined; | |
| 1478 | expect(resolved?.kind).toBe("user-input.resolved"); | |
| 1479 | expect(resolvedPayload?.answers).toEqual({ | |
| 1480 | sandbox_mode: "workspace-write", | |
| 1481 | }); | |
| 1482 | }); | |
| 1483 | ||
| 1220 | 1484 | it("continues processing runtime events after a single event handler failure", async () => { |
| 1221 | 1485 | const harness = await createHarness(); |
| 1222 | 1486 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+167-6| ... | ... | @@ -26,6 +26,8 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId |
| 26 | 26 | const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; |
| 27 | 27 | const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; |
| 28 | 28 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); |
| 29 | const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; | |
| 30 | const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL = Duration.minutes(120); | |
| 29 | 31 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| 30 | 32 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); |
| 31 | 33 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; |
| ... | ... | @@ -65,6 +67,14 @@ function truncateDetail(value: string, limit = 180): string { |
| 65 | 67 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; |
| 66 | 68 | } |
| 67 | 69 | |
| 70 | function wrapProposedPlanMessage(planMarkdown: string | undefined): string | undefined { | |
| 71 | const trimmed = planMarkdown?.trim(); | |
| 72 | if (!trimmed) { | |
| 73 | return undefined; | |
| 74 | } | |
| 75 | return `<proposed_plan>\n${trimmed}\n</proposed_plan>`; | |
| 76 | } | |
| 77 | ||
| 68 | 78 | function asString(value: unknown): string | undefined { |
| 69 | 79 | return typeof value === "string" ? value : undefined; |
| 70 | 80 | } |
| ... | ... | @@ -168,6 +178,9 @@ function runtimeEventToActivities( |
| 168 | 178 | })(); |
| 169 | 179 | switch (event.type) { |
| 170 | 180 | case "request.opened": { |
| 181 | if (event.payload.requestType === "tool_user_input") { | |
| 182 | return []; | |
| 183 | } | |
| 171 | 184 | const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); |
| 172 | 185 | return [ |
| 173 | 186 | { |
| ... | ... | @@ -196,6 +209,9 @@ function runtimeEventToActivities( |
| 196 | 209 | } |
| 197 | 210 | |
| 198 | 211 | case "request.resolved": { |
| 212 | if (event.payload.requestType === "tool_user_input") { | |
| 213 | return []; | |
| 214 | } | |
| 199 | 215 | const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); |
| 200 | 216 | return [ |
| 201 | 217 | { |
| ... | ... | @@ -273,6 +289,111 @@ function runtimeEventToActivities( |
| 273 | 289 | ]; |
| 274 | 290 | } |
| 275 | 291 | |
| 292 | case "user-input.requested": { | |
| 293 | return [ | |
| 294 | { | |
| 295 | id: event.eventId, | |
| 296 | createdAt: event.createdAt, | |
| 297 | tone: "info", | |
| 298 | kind: "user-input.requested", | |
| 299 | summary: "User input requested", | |
| 300 | payload: { | |
| 301 | ...(event.requestId ? { requestId: event.requestId } : {}), | |
| 302 | questions: event.payload.questions, | |
| 303 | }, | |
| 304 | turnId: toTurnId(event.turnId) ?? null, | |
| 305 | ...maybeSequence, | |
| 306 | }, | |
| 307 | ]; | |
| 308 | } | |
| 309 | ||
| 310 | case "user-input.resolved": { | |
| 311 | return [ | |
| 312 | { | |
| 313 | id: event.eventId, | |
| 314 | createdAt: event.createdAt, | |
| 315 | tone: "info", | |
| 316 | kind: "user-input.resolved", | |
| 317 | summary: "User input submitted", | |
| 318 | payload: { | |
| 319 | ...(event.requestId ? { requestId: event.requestId } : {}), | |
| 320 | answers: event.payload.answers, | |
| 321 | }, | |
| 322 | turnId: toTurnId(event.turnId) ?? null, | |
| 323 | ...maybeSequence, | |
| 324 | }, | |
| 325 | ]; | |
| 326 | } | |
| 327 | ||
| 328 | case "task.started": { | |
| 329 | return [ | |
| 330 | { | |
| 331 | id: event.eventId, | |
| 332 | createdAt: event.createdAt, | |
| 333 | tone: "info", | |
| 334 | kind: "task.started", | |
| 335 | summary: | |
| 336 | event.payload.taskType === "plan" | |
| 337 | ? "Plan task started" | |
| 338 | : event.payload.taskType | |
| 339 | ? `${event.payload.taskType} task started` | |
| 340 | : "Task started", | |
| 341 | payload: { | |
| 342 | taskId: event.payload.taskId, | |
| 343 | ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), | |
| 344 | ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), | |
| 345 | }, | |
| 346 | turnId: toTurnId(event.turnId) ?? null, | |
| 347 | ...maybeSequence, | |
| 348 | }, | |
| 349 | ]; | |
| 350 | } | |
| 351 | ||
| 352 | case "task.progress": { | |
| 353 | return [ | |
| 354 | { | |
| 355 | id: event.eventId, | |
| 356 | createdAt: event.createdAt, | |
| 357 | tone: "info", | |
| 358 | kind: "task.progress", | |
| 359 | summary: "Reasoning update", | |
| 360 | payload: { | |
| 361 | taskId: event.payload.taskId, | |
| 362 | detail: truncateDetail(event.payload.description), | |
| 363 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), | |
| 364 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), | |
| 365 | }, | |
| 366 | turnId: toTurnId(event.turnId) ?? null, | |
| 367 | ...maybeSequence, | |
| 368 | }, | |
| 369 | ]; | |
| 370 | } | |
| 371 | ||
| 372 | case "task.completed": { | |
| 373 | return [ | |
| 374 | { | |
| 375 | id: event.eventId, | |
| 376 | createdAt: event.createdAt, | |
| 377 | tone: event.payload.status === "failed" ? "error" : "info", | |
| 378 | kind: "task.completed", | |
| 379 | summary: | |
| 380 | event.payload.status === "failed" | |
| 381 | ? "Task failed" | |
| 382 | : event.payload.status === "stopped" | |
| 383 | ? "Task stopped" | |
| 384 | : "Task completed", | |
| 385 | payload: { | |
| 386 | taskId: event.payload.taskId, | |
| 387 | status: event.payload.status, | |
| 388 | ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), | |
| 389 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), | |
| 390 | }, | |
| 391 | turnId: toTurnId(event.turnId) ?? null, | |
| 392 | ...maybeSequence, | |
| 393 | }, | |
| 394 | ]; | |
| 395 | } | |
| 396 | ||
| 276 | 397 | case "item.updated": { |
| 277 | 398 | if (!isToolLifecycleItemType(event.payload.itemType)) { |
| 278 | 399 | return []; |
| ... | ... | @@ -359,6 +480,12 @@ const make = Effect.gen(function* () { |
| 359 | 480 | lookup: () => Effect.succeed(new Set<MessageId>()), |
| 360 | 481 | }); |
| 361 | 482 | |
| 483 | const messageStreamKindByMessageId = yield* Cache.make<MessageId, "assistant_text" | "plan_text">({ | |
| 484 | capacity: MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY, | |
| 485 | timeToLive: MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL, | |
| 486 | lookup: () => Effect.succeed("assistant_text"), | |
| 487 | }); | |
| 488 | ||
| 362 | 489 | const bufferedAssistantTextByMessageId = yield* Cache.make<MessageId, string>({ |
| 363 | 490 | capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, |
| 364 | 491 | timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, |
| ... | ... | @@ -418,6 +545,16 @@ const make = Effect.gen(function* () { |
| 418 | 545 | const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => |
| 419 | 546 | Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); |
| 420 | 547 | |
| 548 | const rememberMessageStreamKind = ( | |
| 549 | messageId: MessageId, | |
| 550 | streamKind: "assistant_text" | "plan_text", | |
| 551 | ) => Cache.set(messageStreamKindByMessageId, messageId, streamKind); | |
| 552 | ||
| 553 | const getMessageStreamKind = (messageId: MessageId) => | |
| 554 | Cache.getOption(messageStreamKindByMessageId, messageId).pipe( | |
| 555 | Effect.map((streamKind) => Option.getOrElse(streamKind, () => "assistant_text" as const)), | |
| 556 | ); | |
| 557 | ||
| 421 | 558 | const appendBufferedAssistantText = (messageId: MessageId, delta: string) => |
| 422 | 559 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| 423 | 560 | Effect.flatMap((existingText) => |
| ... | ... | @@ -451,7 +588,10 @@ const make = Effect.gen(function* () { |
| 451 | 588 | Cache.invalidate(bufferedAssistantTextByMessageId, messageId); |
| 452 | 589 | |
| 453 | 590 | const clearAssistantMessageState = (messageId: MessageId) => |
| 454 | clearBufferedAssistantText(messageId); | |
| 591 | Effect.all([ | |
| 592 | clearBufferedAssistantText(messageId), | |
| 593 | Cache.invalidate(messageStreamKindByMessageId, messageId), | |
| 594 | ]).pipe(Effect.asVoid); | |
| 455 | 595 | |
| 456 | 596 | const finalizeAssistantMessage = (input: { |
| 457 | 597 | event: ProviderRuntimeEvent; |
| ... | ... | @@ -464,9 +604,20 @@ const make = Effect.gen(function* () { |
| 464 | 604 | fallbackText?: string; |
| 465 | 605 | }) => |
| 466 | 606 | Effect.gen(function* () { |
| 607 | const messageStreamKind = yield* getMessageStreamKind(input.messageId); | |
| 467 | 608 | const bufferedText = yield* takeBufferedAssistantText(input.messageId); |
| 609 | const bufferedFinalText = | |
| 610 | bufferedText.length > 0 | |
| 611 | ? messageStreamKind === "plan_text" | |
| 612 | ? (wrapProposedPlanMessage(bufferedText) ?? "") | |
| 613 | : bufferedText | |
| 614 | : ""; | |
| 468 | 615 | const text = |
| 469 | bufferedText.length > 0 ? bufferedText : (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : ""; | |
| 616 | bufferedFinalText.length > 0 | |
| 617 | ? bufferedFinalText | |
| 618 | : (input.fallbackText?.trim().length ?? 0) > 0 | |
| 619 | ? input.fallbackText! | |
| 620 | : ""; | |
| 470 | 621 | |
| 471 | 622 | if (text.length > 0) { |
| 472 | 623 | yield* orchestrationEngine.dispatch({ |
| ... | ... | @@ -617,7 +768,8 @@ const make = Effect.gen(function* () { |
| 617 | 768 | } |
| 618 | 769 | |
| 619 | 770 | const assistantDelta = |
| 620 | event.type === "content.delta" && event.payload.streamKind === "assistant_text" | |
| 771 | event.type === "content.delta" && | |
| 772 | (event.payload.streamKind === "assistant_text" || event.payload.streamKind === "plan_text") | |
| 621 | 773 | ? event.payload.delta |
| 622 | 774 | : undefined; |
| 623 | 775 | |
| ... | ... | @@ -626,9 +778,14 @@ const make = Effect.gen(function* () { |
| 626 | 778 | `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, |
| 627 | 779 | ); |
| 628 | 780 | const turnId = toTurnId(event.turnId); |
| 781 | const messageStreamKind = | |
| 782 | event.type === "content.delta" && event.payload.streamKind === "plan_text" | |
| 783 | ? "plan_text" | |
| 784 | : "assistant_text"; | |
| 629 | 785 | if (turnId) { |
| 630 | 786 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 631 | 787 | } |
| 788 | yield* rememberMessageStreamKind(assistantMessageId, messageStreamKind); | |
| 632 | 789 | |
| 633 | 790 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); |
| 634 | 791 | if (assistantDeliveryMode === "buffered") { |
| ... | ... | @@ -658,12 +815,16 @@ const make = Effect.gen(function* () { |
| 658 | 815 | } |
| 659 | 816 | |
| 660 | 817 | const assistantCompletion = |
| 661 | event.type === "item.completed" && event.payload.itemType === "assistant_message" | |
| 818 | event.type === "item.completed" && | |
| 819 | (event.payload.itemType === "assistant_message" || event.payload.itemType === "plan") | |
| 662 | 820 | ? { |
| 663 | 821 | messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), |
| 664 | fallbackText: event.payload.detail, | |
| 822 | fallbackText: | |
| 823 | event.payload.itemType === "plan" | |
| 824 | ? wrapProposedPlanMessage(event.payload.detail) | |
| 825 | : event.payload.detail, | |
| 665 | 826 | } |
| 666 | : undefined; | |
| 827 | : undefined; | |
| 667 | 828 | |
| 668 | 829 | if (assistantCompletion) { |
| 669 | 830 | const assistantMessageId = assistantCompletion.messageId; |
apps/server/src/orchestration/Schemas.ts+2| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, |
| 6 | 6 | ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, |
| 7 | 7 | ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, |
| 8 | ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, | |
| 8 | 9 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, |
| 9 | 10 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 10 | 11 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | ... | @@ -26,6 +27,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; |
| 26 | 27 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; |
| 27 | 28 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; |
| 28 | 29 | export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; |
| 30 | export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; | |
| 29 | 31 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 30 | 32 | |
| 31 | 33 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
apps/server/src/orchestration/commandInvariants.test.ts+6| ... | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { |
| 3 | 3 | MessageId, |
| 4 | 4 | CommandId, |
| 5 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 5 | 6 | ProjectId, |
| 6 | 7 | ThreadId, |
| 7 | 8 | type OrchestrationCommand, |
| ... | ... | @@ -50,6 +51,7 @@ const readModel: OrchestrationReadModel = { |
| 50 | 51 | projectId: ProjectId.makeUnsafe("project-a"), |
| 51 | 52 | title: "Thread A", |
| 52 | 53 | model: "gpt-5-codex", |
| 54 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 53 | 55 | runtimeMode: "full-access", |
| 54 | 56 | branch: null, |
| 55 | 57 | worktreePath: null, |
| ... | ... | @@ -67,6 +69,7 @@ const readModel: OrchestrationReadModel = { |
| 67 | 69 | projectId: ProjectId.makeUnsafe("project-b"), |
| 68 | 70 | title: "Thread B", |
| 69 | 71 | model: "gpt-5-codex", |
| 72 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 70 | 73 | runtimeMode: "full-access", |
| 71 | 74 | branch: null, |
| 72 | 75 | worktreePath: null, |
| ... | ... | @@ -92,6 +95,7 @@ const messageSendCommand: OrchestrationCommand = { |
| 92 | 95 | text: "hello", |
| 93 | 96 | attachments: [], |
| 94 | 97 | }, |
| 98 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 95 | 99 | runtimeMode: "approval-required", |
| 96 | 100 | createdAt: now, |
| 97 | 101 | }; |
| ... | ... | @@ -139,6 +143,7 @@ describe("commandInvariants", () => { |
| 139 | 143 | projectId: ProjectId.makeUnsafe("project-a"), |
| 140 | 144 | title: "new", |
| 141 | 145 | model: "gpt-5-codex", |
| 146 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 142 | 147 | runtimeMode: "full-access", |
| 143 | 148 | branch: null, |
| 144 | 149 | worktreePath: null, |
| ... | ... | @@ -159,6 +164,7 @@ describe("commandInvariants", () => { |
| 159 | 164 | projectId: ProjectId.makeUnsafe("project-a"), |
| 160 | 165 | title: "dup", |
| 161 | 166 | model: "gpt-5-codex", |
| 167 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 162 | 168 | runtimeMode: "full-access", |
| 163 | 169 | branch: null, |
| 164 | 170 | worktreePath: null, |
apps/server/src/orchestration/decider.projectScripts.test.ts+90-1| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 4 | EventId, | |
| 5 | MessageId, | |
| 6 | ProjectId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | import { describe, expect, it } from "vitest"; |
| 3 | 10 | import { Effect } from "effect"; |
| 4 | 11 | |
| ... | ... | @@ -130,6 +137,7 @@ describe("decider project scripts", () => { |
| 130 | 137 | projectId: asProjectId("project-1"), |
| 131 | 138 | title: "Thread", |
| 132 | 139 | model: "gpt-5-codex", |
| 140 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 133 | 141 | runtimeMode: "approval-required", |
| 134 | 142 | branch: null, |
| 135 | 143 | worktreePath: null, |
| ... | ... | @@ -154,6 +162,7 @@ describe("decider project scripts", () => { |
| 154 | 162 | provider: "claudeCode", |
| 155 | 163 | model: "gpt-5", |
| 156 | 164 | effort: "high", |
| 165 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 157 | 166 | runtimeMode: "approval-required", |
| 158 | 167 | createdAt: now, |
| 159 | 168 | }, |
| ... | ... | @@ -225,6 +234,7 @@ describe("decider project scripts", () => { |
| 225 | 234 | projectId: asProjectId("project-1"), |
| 226 | 235 | title: "Thread", |
| 227 | 236 | model: "gpt-5-codex", |
| 237 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 228 | 238 | runtimeMode: "full-access", |
| 229 | 239 | branch: null, |
| 230 | 240 | worktreePath: null, |
| ... | ... | @@ -259,4 +269,83 @@ describe("decider project scripts", () => { |
| 259 | 269 | }, |
| 260 | 270 | }); |
| 261 | 271 | }); |
| 272 | ||
| 273 | it("emits thread.interaction-mode-set from thread.interaction-mode.set", async () => { | |
| 274 | const now = new Date().toISOString(); | |
| 275 | const initial = createEmptyReadModel(now); | |
| 276 | const withProject = await Effect.runPromise( | |
| 277 | projectEvent(initial, { | |
| 278 | sequence: 1, | |
| 279 | eventId: asEventId("evt-project-create"), | |
| 280 | aggregateKind: "project", | |
| 281 | aggregateId: asProjectId("project-1"), | |
| 282 | type: "project.created", | |
| 283 | occurredAt: now, | |
| 284 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 285 | causationEventId: null, | |
| 286 | correlationId: CommandId.makeUnsafe("cmd-project-create"), | |
| 287 | metadata: {}, | |
| 288 | payload: { | |
| 289 | projectId: asProjectId("project-1"), | |
| 290 | title: "Project", | |
| 291 | workspaceRoot: "/tmp/project", | |
| 292 | defaultModel: null, | |
| 293 | scripts: [], | |
| 294 | createdAt: now, | |
| 295 | updatedAt: now, | |
| 296 | }, | |
| 297 | }), | |
| 298 | ); | |
| 299 | const readModel = await Effect.runPromise( | |
| 300 | projectEvent(withProject, { | |
| 301 | sequence: 2, | |
| 302 | eventId: asEventId("evt-thread-create"), | |
| 303 | aggregateKind: "thread", | |
| 304 | aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 305 | type: "thread.created", | |
| 306 | occurredAt: now, | |
| 307 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 308 | causationEventId: null, | |
| 309 | correlationId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 310 | metadata: {}, | |
| 311 | payload: { | |
| 312 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 313 | projectId: asProjectId("project-1"), | |
| 314 | title: "Thread", | |
| 315 | model: "gpt-5-codex", | |
| 316 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 317 | runtimeMode: "approval-required", | |
| 318 | branch: null, | |
| 319 | worktreePath: null, | |
| 320 | createdAt: now, | |
| 321 | updatedAt: now, | |
| 322 | }, | |
| 323 | }), | |
| 324 | ); | |
| 325 | ||
| 326 | const result = await Effect.runPromise( | |
| 327 | decideOrchestrationCommand({ | |
| 328 | command: { | |
| 329 | type: "thread.interaction-mode.set", | |
| 330 | commandId: CommandId.makeUnsafe("cmd-interaction-mode-set"), | |
| 331 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 332 | interactionMode: "plan", | |
| 333 | createdAt: now, | |
| 334 | }, | |
| 335 | readModel, | |
| 336 | }), | |
| 337 | ); | |
| 338 | ||
| 339 | const singleResult = Array.isArray(result) ? null : result; | |
| 340 | if (singleResult === null) { | |
| 341 | throw new Error("Expected a single interaction-mode-set event."); | |
| 342 | } | |
| 343 | expect(singleResult).toMatchObject({ | |
| 344 | type: "thread.interaction-mode-set", | |
| 345 | payload: { | |
| 346 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 347 | interactionMode: "plan", | |
| 348 | }, | |
| 349 | }); | |
| 350 | }); | |
| 262 | 351 | }); |
apps/server/src/orchestration/decider.ts+53| ... | ... | @@ -158,6 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 158 | 158 | title: command.title, |
| 159 | 159 | model: command.model, |
| 160 | 160 | runtimeMode: command.runtimeMode, |
| 161 | interactionMode: command.interactionMode, | |
| 161 | 162 | branch: command.branch, |
| 162 | 163 | worktreePath: command.worktreePath, |
| 163 | 164 | createdAt: command.createdAt, |
| ... | ... | @@ -237,6 +238,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 237 | 238 | }; |
| 238 | 239 | } |
| 239 | 240 | |
| 241 | case "thread.interaction-mode.set": { | |
| 242 | yield* requireThread({ | |
| 243 | readModel, | |
| 244 | command, | |
| 245 | threadId: command.threadId, | |
| 246 | }); | |
| 247 | const occurredAt = nowIso(); | |
| 248 | return { | |
| 249 | ...withEventBase({ | |
| 250 | aggregateKind: "thread", | |
| 251 | aggregateId: command.threadId, | |
| 252 | occurredAt, | |
| 253 | commandId: command.commandId, | |
| 254 | }), | |
| 255 | type: "thread.interaction-mode-set", | |
| 256 | payload: { | |
| 257 | threadId: command.threadId, | |
| 258 | interactionMode: command.interactionMode, | |
| 259 | updatedAt: occurredAt, | |
| 260 | }, | |
| 261 | }; | |
| 262 | } | |
| 263 | ||
| 240 | 264 | case "thread.turn.start": { |
| 241 | 265 | yield* requireThread({ |
| 242 | 266 | readModel, |
| ... | ... | @@ -282,6 +306,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 282 | 306 | runtimeMode: |
| 283 | 307 | readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? |
| 284 | 308 | command.runtimeMode, |
| 309 | interactionMode: | |
| 310 | readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ?? | |
| 311 | command.interactionMode, | |
| 285 | 312 | createdAt: command.createdAt, |
| 286 | 313 | }, |
| 287 | 314 | }; |
| ... | ... | @@ -336,6 +363,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 336 | 363 | }; |
| 337 | 364 | } |
| 338 | 365 | |
| 366 | case "thread.user-input.respond": { | |
| 367 | yield* requireThread({ | |
| 368 | readModel, | |
| 369 | command, | |
| 370 | threadId: command.threadId, | |
| 371 | }); | |
| 372 | return { | |
| 373 | ...withEventBase({ | |
| 374 | aggregateKind: "thread", | |
| 375 | aggregateId: command.threadId, | |
| 376 | occurredAt: command.createdAt, | |
| 377 | commandId: command.commandId, | |
| 378 | metadata: { | |
| 379 | requestId: command.requestId, | |
| 380 | }, | |
| 381 | }), | |
| 382 | type: "thread.user-input-response-requested", | |
| 383 | payload: { | |
| 384 | threadId: command.threadId, | |
| 385 | requestId: command.requestId, | |
| 386 | answers: command.answers, | |
| 387 | createdAt: command.createdAt, | |
| 388 | }, | |
| 389 | }; | |
| 390 | } | |
| 391 | ||
| 339 | 392 | case "thread.checkpoint.revert": { |
| 340 | 393 | yield* requireThread({ |
| 341 | 394 | readModel, |
apps/server/src/orchestration/projector.ts+18| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | ThreadActivityAppendedPayload, |
| 17 | 17 | ThreadCreatedPayload, |
| 18 | 18 | ThreadDeletedPayload, |
| 19 | ThreadInteractionModeSetPayload, | |
| 19 | 20 | ThreadMetaUpdatedPayload, |
| 20 | 21 | ThreadRuntimeModeSetPayload, |
| 21 | 22 | ThreadRevertedPayload, |
| ... | ... | @@ -243,6 +244,7 @@ export function projectEvent( |
| 243 | 244 | title: payload.title, |
| 244 | 245 | model: payload.model, |
| 245 | 246 | runtimeMode: payload.runtimeMode, |
| 247 | interactionMode: payload.interactionMode, | |
| 246 | 248 | branch: payload.branch, |
| 247 | 249 | worktreePath: payload.worktreePath, |
| 248 | 250 | latestTurn: null, |
| ... | ... | @@ -307,6 +309,22 @@ export function projectEvent( |
| 307 | 309 | })), |
| 308 | 310 | ); |
| 309 | 311 | |
| 312 | case "thread.interaction-mode-set": | |
| 313 | return decodeForEvent( | |
| 314 | ThreadInteractionModeSetPayload, | |
| 315 | event.payload, | |
| 316 | event.type, | |
| 317 | "payload", | |
| 318 | ).pipe( | |
| 319 | Effect.map((payload) => ({ | |
| 320 | ...nextBase, | |
| 321 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 322 | interactionMode: payload.interactionMode, | |
| 323 | updatedAt: payload.updatedAt, | |
| 324 | }), | |
| 325 | })), | |
| 326 | ); | |
| 327 | ||
| 310 | 328 | case "thread.message-sent": |
| 311 | 329 | return Effect.gen(function* () { |
| 312 | 330 | const payload = yield* decodeForEvent( |
apps/server/src/persistence/Layers/ProjectionThreads.ts+5| ... | ... | @@ -25,6 +25,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 25 | 25 | title, |
| 26 | 26 | model, |
| 27 | 27 | runtime_mode, |
| 28 | interaction_mode, | |
| 28 | 29 | branch, |
| 29 | 30 | worktree_path, |
| 30 | 31 | latest_turn_id, |
| ... | ... | @@ -38,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 38 | 39 | ${row.title}, |
| 39 | 40 | ${row.model}, |
| 40 | 41 | ${row.runtimeMode}, |
| 42 | ${row.interactionMode}, | |
| 41 | 43 | ${row.branch}, |
| 42 | 44 | ${row.worktreePath}, |
| 43 | 45 | ${row.latestTurnId}, |
| ... | ... | @@ -51,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 51 | 53 | title = excluded.title, |
| 52 | 54 | model = excluded.model, |
| 53 | 55 | runtime_mode = excluded.runtime_mode, |
| 56 | interaction_mode = excluded.interaction_mode, | |
| 54 | 57 | branch = excluded.branch, |
| 55 | 58 | worktree_path = excluded.worktree_path, |
| 56 | 59 | latest_turn_id = excluded.latest_turn_id, |
| ... | ... | @@ -71,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 71 | 74 | title, |
| 72 | 75 | model, |
| 73 | 76 | runtime_mode AS "runtimeMode", |
| 77 | interaction_mode AS "interactionMode", | |
| 74 | 78 | branch, |
| 75 | 79 | worktree_path AS "worktreePath", |
| 76 | 80 | latest_turn_id AS "latestTurnId", |
| ... | ... | @@ -93,6 +97,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 93 | 97 | title, |
| 94 | 98 | model, |
| 95 | 99 | runtime_mode AS "runtimeMode", |
| 100 | interaction_mode AS "interactionMode", | |
| 96 | 101 | branch, |
| 97 | 102 | worktree_path AS "worktreePath", |
| 98 | 103 | latest_turn_id AS "latestTurnId", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -23,6 +23,7 @@ import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts" |
| 23 | 23 | import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; |
| 24 | 24 | import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; |
| 25 | 25 | import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; |
| 26 | import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; | |
| 26 | 27 | import { Effect } from "effect"; |
| 27 | 28 | |
| 28 | 29 | /** |
| ... | ... | @@ -47,6 +48,7 @@ const loader = Migrator.fromRecord({ |
| 47 | 48 | "9_ProviderSessionRuntimeMode": Migration0009, |
| 48 | 49 | "10_ProjectionThreadsRuntimeMode": Migration0010, |
| 49 | 50 | "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, |
| 51 | "12_ProjectionThreadsInteractionMode": Migration0012, | |
| 50 | 52 | }); |
| 51 | 53 | |
| 52 | 54 | /** |
apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | ALTER TABLE projection_threads | |
| 9 | ADD COLUMN interaction_mode TEXT NOT NULL DEFAULT 'default' | |
| 10 | `; | |
| 11 | }); |
apps/server/src/persistence/Services/ProjectionThreads.ts+9-1| ... | ... | @@ -6,7 +6,14 @@ |
| 6 | 6 | * |
| 7 | 7 | * @module ProjectionThreadRepository |
| 8 | 8 | */ |
| 9 | import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | IsoDateTime, | |
| 11 | ProjectId, | |
| 12 | ProviderInteractionMode, | |
| 13 | RuntimeMode, | |
| 14 | ThreadId, | |
| 15 | TurnId, | |
| 16 | } from "@t3tools/contracts"; | |
| 10 | 17 | import { Option, Schema, ServiceMap } from "effect"; |
| 11 | 18 | import type { Effect } from "effect"; |
| 12 | 19 | |
| ... | ... | @@ -18,6 +25,7 @@ export const ProjectionThread = Schema.Struct({ |
| 18 | 25 | title: Schema.String, |
| 19 | 26 | model: Schema.String, |
| 20 | 27 | runtimeMode: RuntimeMode, |
| 28 | interactionMode: ProviderInteractionMode, | |
| 21 | 29 | branch: Schema.NullOr(Schema.String), |
| 22 | 30 | worktreePath: Schema.NullOr(Schema.String), |
| 23 | 31 | latestTurnId: Schema.NullOr(TurnId), |
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+14| ... | ... | @@ -1781,6 +1781,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1781 | 1781 | yield* Deferred.succeed(pending.decision, decision); |
| 1782 | 1782 | }); |
| 1783 | 1783 | |
| 1784 | const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( | |
| 1785 | threadId, | |
| 1786 | requestId, | |
| 1787 | _answers, | |
| 1788 | ) => | |
| 1789 | Effect.fail( | |
| 1790 | new ProviderAdapterRequestError({ | |
| 1791 | provider: PROVIDER, | |
| 1792 | method: "item/tool/requestUserInput", | |
| 1793 | detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, | |
| 1794 | }), | |
| 1795 | ); | |
| 1796 | ||
| 1784 | 1797 | const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => |
| 1785 | 1798 | Effect.gen(function* () { |
| 1786 | 1799 | const context = yield* requireSession(threadId); |
| ... | ... | @@ -1830,6 +1843,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1830 | 1843 | readThread, |
| 1831 | 1844 | rollbackThread, |
| 1832 | 1845 | respondToRequest, |
| 1846 | respondToUserInput, | |
| 1833 | 1847 | stopSession, |
| 1834 | 1848 | listSessions, |
| 1835 | 1849 | hasSession, |
apps/server/src/provider/Layers/CodexAdapter.test.ts+233| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type ProviderSession, |
| 8 | 8 | type ProviderSessionStartInput, |
| 9 | 9 | type ProviderTurnStartResult, |
| 10 | type ProviderUserInputAnswers, | |
| 10 | 11 | ThreadId, |
| 11 | 12 | TurnId, |
| 12 | 13 | } from "@t3tools/contracts"; |
| ... | ... | @@ -76,6 +77,14 @@ class FakeCodexManager extends CodexAppServerManager { |
| 76 | 77 | ): Promise<void> => undefined, |
| 77 | 78 | ); |
| 78 | 79 | |
| 80 | public respondToUserInputImpl = vi.fn( | |
| 81 | async ( | |
| 82 | _threadId: ThreadId, | |
| 83 | _requestId: ApprovalRequestId, | |
| 84 | _answers: ProviderUserInputAnswers, | |
| 85 | ): Promise<void> => undefined, | |
| 86 | ); | |
| 87 | ||
| 79 | 88 | public stopAllImpl = vi.fn(() => undefined); |
| 80 | 89 | |
| 81 | 90 | override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| ... | ... | @@ -106,6 +115,14 @@ class FakeCodexManager extends CodexAppServerManager { |
| 106 | 115 | return this.respondToRequestImpl(threadId, requestId, decision); |
| 107 | 116 | } |
| 108 | 117 | |
| 118 | override respondToUserInput( | |
| 119 | threadId: ThreadId, | |
| 120 | requestId: ApprovalRequestId, | |
| 121 | answers: ProviderUserInputAnswers, | |
| 122 | ): Promise<void> { | |
| 123 | return this.respondToUserInputImpl(threadId, requestId, answers); | |
| 124 | } | |
| 125 | ||
| 109 | 126 | override stopSession(_threadId: ThreadId): void {} |
| 110 | 127 | |
| 111 | 128 | override listSessions(): ProviderSession[] { |
| ... | ... | @@ -253,6 +270,46 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 253 | 270 | }), |
| 254 | 271 | ); |
| 255 | 272 | |
| 273 | it.effect("maps completed plan items to canonical item.completed plan events", () => | |
| 274 | Effect.gen(function* () { | |
| 275 | const adapter = yield* CodexAdapter; | |
| 276 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | |
| 277 | ||
| 278 | const event: ProviderEvent = { | |
| 279 | id: asEventId("evt-plan-complete"), | |
| 280 | kind: "notification", | |
| 281 | provider: "codex", | |
| 282 | createdAt: new Date().toISOString(), | |
| 283 | method: "item/completed", | |
| 284 | threadId: asThreadId("thread-1"), | |
| 285 | turnId: asTurnId("turn-1"), | |
| 286 | itemId: asItemId("plan_1"), | |
| 287 | payload: { | |
| 288 | item: { | |
| 289 | type: "Plan", | |
| 290 | id: "plan_1", | |
| 291 | text: "## Final plan\n\n- one\n- two", | |
| 292 | }, | |
| 293 | }, | |
| 294 | }; | |
| 295 | ||
| 296 | lifecycleManager.emit("event", event); | |
| 297 | const firstEvent = yield* Fiber.join(firstEventFiber); | |
| 298 | ||
| 299 | assert.equal(firstEvent._tag, "Some"); | |
| 300 | if (firstEvent._tag !== "Some") { | |
| 301 | return; | |
| 302 | } | |
| 303 | assert.equal(firstEvent.value.type, "item.completed"); | |
| 304 | if (firstEvent.value.type !== "item.completed") { | |
| 305 | return; | |
| 306 | } | |
| 307 | assert.equal(firstEvent.value.itemId, "plan_1"); | |
| 308 | assert.equal(firstEvent.value.payload.itemType, "plan"); | |
| 309 | assert.equal(firstEvent.value.payload.detail, "## Final plan\n\n- one\n- two"); | |
| 310 | }), | |
| 311 | ); | |
| 312 | ||
| 256 | 313 | it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => |
| 257 | 314 | Effect.gen(function* () { |
| 258 | 315 | const adapter = yield* CodexAdapter; |
| ... | ... | @@ -361,6 +418,182 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 361 | 418 | } |
| 362 | 419 | }), |
| 363 | 420 | ); |
| 421 | ||
| 422 | it.effect("maps requestUserInput requests and answered notifications to canonical user-input events", () => | |
| 423 | Effect.gen(function* () { | |
| 424 | const adapter = yield* CodexAdapter; | |
| 425 | const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( | |
| 426 | Effect.forkChild, | |
| 427 | ); | |
| 428 | ||
| 429 | lifecycleManager.emit("event", { | |
| 430 | id: asEventId("evt-user-input-requested"), | |
| 431 | kind: "request", | |
| 432 | provider: "codex", | |
| 433 | threadId: asThreadId("thread-1"), | |
| 434 | createdAt: new Date().toISOString(), | |
| 435 | method: "item/tool/requestUserInput", | |
| 436 | requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 437 | payload: { | |
| 438 | questions: [ | |
| 439 | { | |
| 440 | id: "sandbox_mode", | |
| 441 | header: "Sandbox", | |
| 442 | question: "Which mode should be used?", | |
| 443 | options: [ | |
| 444 | { | |
| 445 | label: "workspace-write", | |
| 446 | description: "Allow workspace writes only", | |
| 447 | }, | |
| 448 | ], | |
| 449 | }, | |
| 450 | ], | |
| 451 | }, | |
| 452 | } satisfies ProviderEvent); | |
| 453 | lifecycleManager.emit("event", { | |
| 454 | id: asEventId("evt-user-input-resolved"), | |
| 455 | kind: "notification", | |
| 456 | provider: "codex", | |
| 457 | threadId: asThreadId("thread-1"), | |
| 458 | createdAt: new Date().toISOString(), | |
| 459 | method: "item/tool/requestUserInput/answered", | |
| 460 | requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 461 | payload: { | |
| 462 | answers: { | |
| 463 | sandbox_mode: { | |
| 464 | answers: ["workspace-write"], | |
| 465 | }, | |
| 466 | }, | |
| 467 | }, | |
| 468 | } satisfies ProviderEvent); | |
| 469 | ||
| 470 | const events = Array.from(yield* Fiber.join(eventsFiber)); | |
| 471 | assert.equal(events[0]?.type, "user-input.requested"); | |
| 472 | if (events[0]?.type === "user-input.requested") { | |
| 473 | assert.equal(events[0].requestId, "req-user-input-1"); | |
| 474 | assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); | |
| 475 | } | |
| 476 | ||
| 477 | assert.equal(events[1]?.type, "user-input.resolved"); | |
| 478 | if (events[1]?.type === "user-input.resolved") { | |
| 479 | assert.equal(events[1].requestId, "req-user-input-1"); | |
| 480 | assert.deepEqual(events[1].payload.answers, { | |
| 481 | sandbox_mode: "workspace-write", | |
| 482 | }); | |
| 483 | } | |
| 484 | }), | |
| 485 | ); | |
| 486 | ||
| 487 | it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () => | |
| 488 | Effect.gen(function* () { | |
| 489 | const adapter = yield* CodexAdapter; | |
| 490 | const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe( | |
| 491 | Effect.forkChild, | |
| 492 | ); | |
| 493 | ||
| 494 | lifecycleManager.emit("event", { | |
| 495 | id: asEventId("evt-codex-task-started"), | |
| 496 | kind: "notification", | |
| 497 | provider: "codex", | |
| 498 | threadId: asThreadId("thread-1"), | |
| 499 | createdAt: new Date().toISOString(), | |
| 500 | method: "codex/event/task_started", | |
| 501 | payload: { | |
| 502 | id: "turn-structured-1", | |
| 503 | msg: { | |
| 504 | type: "task_started", | |
| 505 | turn_id: "turn-structured-1", | |
| 506 | collaboration_mode_kind: "plan", | |
| 507 | }, | |
| 508 | }, | |
| 509 | } satisfies ProviderEvent); | |
| 510 | ||
| 511 | lifecycleManager.emit("event", { | |
| 512 | id: asEventId("evt-codex-agent-reasoning"), | |
| 513 | kind: "notification", | |
| 514 | provider: "codex", | |
| 515 | threadId: asThreadId("thread-1"), | |
| 516 | createdAt: new Date().toISOString(), | |
| 517 | method: "codex/event/agent_reasoning", | |
| 518 | payload: { | |
| 519 | id: "turn-structured-1", | |
| 520 | msg: { | |
| 521 | type: "agent_reasoning", | |
| 522 | text: "Need to compare both transport layers before finalizing the plan.", | |
| 523 | }, | |
| 524 | }, | |
| 525 | } satisfies ProviderEvent); | |
| 526 | ||
| 527 | lifecycleManager.emit("event", { | |
| 528 | id: asEventId("evt-codex-reasoning-delta"), | |
| 529 | kind: "notification", | |
| 530 | provider: "codex", | |
| 531 | threadId: asThreadId("thread-1"), | |
| 532 | createdAt: new Date().toISOString(), | |
| 533 | method: "codex/event/reasoning_content_delta", | |
| 534 | payload: { | |
| 535 | id: "turn-structured-1", | |
| 536 | msg: { | |
| 537 | type: "reasoning_content_delta", | |
| 538 | turn_id: "turn-structured-1", | |
| 539 | item_id: "rs_reasoning_1", | |
| 540 | delta: "**Compare** transport boundaries", | |
| 541 | summary_index: 0, | |
| 542 | }, | |
| 543 | }, | |
| 544 | } satisfies ProviderEvent); | |
| 545 | ||
| 546 | lifecycleManager.emit("event", { | |
| 547 | id: asEventId("evt-codex-task-complete"), | |
| 548 | kind: "notification", | |
| 549 | provider: "codex", | |
| 550 | threadId: asThreadId("thread-1"), | |
| 551 | createdAt: new Date().toISOString(), | |
| 552 | method: "codex/event/task_complete", | |
| 553 | payload: { | |
| 554 | id: "turn-structured-1", | |
| 555 | msg: { | |
| 556 | type: "task_complete", | |
| 557 | turn_id: "turn-structured-1", | |
| 558 | last_agent_message: "<proposed_plan>\n# Ship it\n</proposed_plan>", | |
| 559 | }, | |
| 560 | }, | |
| 561 | } satisfies ProviderEvent); | |
| 562 | ||
| 563 | const events = Array.from(yield* Fiber.join(eventsFiber)); | |
| 564 | ||
| 565 | assert.equal(events[0]?.type, "task.started"); | |
| 566 | if (events[0]?.type === "task.started") { | |
| 567 | assert.equal(events[0].turnId, "turn-structured-1"); | |
| 568 | assert.equal(events[0].payload.taskId, "turn-structured-1"); | |
| 569 | assert.equal(events[0].payload.taskType, "plan"); | |
| 570 | } | |
| 571 | ||
| 572 | assert.equal(events[1]?.type, "task.progress"); | |
| 573 | if (events[1]?.type === "task.progress") { | |
| 574 | assert.equal(events[1].payload.taskId, "turn-structured-1"); | |
| 575 | assert.equal( | |
| 576 | events[1].payload.description, | |
| 577 | "Need to compare both transport layers before finalizing the plan.", | |
| 578 | ); | |
| 579 | } | |
| 580 | ||
| 581 | assert.equal(events[2]?.type, "content.delta"); | |
| 582 | if (events[2]?.type === "content.delta") { | |
| 583 | assert.equal(events[2].turnId, "turn-structured-1"); | |
| 584 | assert.equal(events[2].itemId, "rs_reasoning_1"); | |
| 585 | assert.equal(events[2].payload.streamKind, "reasoning_summary_text"); | |
| 586 | assert.equal(events[2].payload.summaryIndex, 0); | |
| 587 | } | |
| 588 | ||
| 589 | assert.equal(events[3]?.type, "task.completed"); | |
| 590 | if (events[3]?.type === "task.completed") { | |
| 591 | assert.equal(events[3].turnId, "turn-structured-1"); | |
| 592 | assert.equal(events[3].payload.taskId, "turn-structured-1"); | |
| 593 | assert.equal(events[3].payload.summary, "<proposed_plan>\n# Ship it\n</proposed_plan>"); | |
| 594 | } | |
| 595 | }), | |
| 596 | ); | |
| 364 | 597 | }); |
| 365 | 598 | |
| 366 | 599 | afterAll(() => { |
apps/server/src/provider/Layers/CodexAdapter.ts+255| ... | ... | @@ -11,11 +11,14 @@ import { |
| 11 | 11 | type CanonicalRequestType, |
| 12 | 12 | type ProviderEvent, |
| 13 | 13 | type ProviderRuntimeEvent, |
| 14 | type ProviderUserInputAnswers, | |
| 14 | 15 | RuntimeItemId, |
| 15 | 16 | RuntimeRequestId, |
| 17 | RuntimeTaskId, | |
| 16 | 18 | ProviderApprovalDecision, |
| 17 | 19 | ProviderItemId, |
| 18 | 20 | ThreadId, |
| 21 | TurnId, | |
| 19 | 22 | } from "@t3tools/contracts"; |
| 20 | 23 | import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; |
| 21 | 24 | |
| ... | ... | @@ -95,6 +98,10 @@ function asString(value: unknown): string | undefined { |
| 95 | 98 | return typeof value === "string" ? value : undefined; |
| 96 | 99 | } |
| 97 | 100 | |
| 101 | function asArray(value: unknown): unknown[] | undefined { | |
| 102 | return Array.isArray(value) ? value : undefined; | |
| 103 | } | |
| 104 | ||
| 98 | 105 | function asNumber(value: unknown): number | undefined { |
| 99 | 106 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; |
| 100 | 107 | } |
| ... | ... | @@ -249,6 +256,88 @@ function toRequestTypeFromResolvedPayload( |
| 249 | 256 | return "unknown"; |
| 250 | 257 | } |
| 251 | 258 | |
| 259 | function toCanonicalUserInputAnswers( | |
| 260 | answers: ProviderUserInputAnswers | undefined, | |
| 261 | ): ProviderUserInputAnswers { | |
| 262 | if (!answers) { | |
| 263 | return {}; | |
| 264 | } | |
| 265 | ||
| 266 | return Object.fromEntries( | |
| 267 | Object.entries(answers).flatMap(([questionId, value]) => { | |
| 268 | if (typeof value === "string") { | |
| 269 | return [[questionId, value] as const]; | |
| 270 | } | |
| 271 | ||
| 272 | if (Array.isArray(value)) { | |
| 273 | const normalized = value.filter((entry): entry is string => typeof entry === "string"); | |
| 274 | if (normalized.length === 0) { | |
| 275 | return []; | |
| 276 | } | |
| 277 | return [[questionId, normalized.length === 1 ? normalized[0] : normalized] as const]; | |
| 278 | } | |
| 279 | ||
| 280 | const answerObject = asObject(value); | |
| 281 | const answerList = asArray(answerObject?.answers)?.filter( | |
| 282 | (entry): entry is string => typeof entry === "string", | |
| 283 | ); | |
| 284 | if (!answerList || answerList.length === 0) { | |
| 285 | return []; | |
| 286 | } | |
| 287 | return [[questionId, answerList.length === 1 ? answerList[0] : answerList] as const]; | |
| 288 | }), | |
| 289 | ); | |
| 290 | } | |
| 291 | ||
| 292 | function toUserInputQuestions(payload: Record<string, unknown> | undefined) { | |
| 293 | const questions = asArray(payload?.questions); | |
| 294 | if (!questions) { | |
| 295 | return undefined; | |
| 296 | } | |
| 297 | ||
| 298 | const parsedQuestions = questions | |
| 299 | .map((entry) => { | |
| 300 | const question = asObject(entry); | |
| 301 | if (!question) return undefined; | |
| 302 | const options = asArray(question.options) | |
| 303 | ?.map((option) => { | |
| 304 | const optionRecord = asObject(option); | |
| 305 | if (!optionRecord) return undefined; | |
| 306 | const label = asString(optionRecord.label)?.trim(); | |
| 307 | const description = asString(optionRecord.description)?.trim(); | |
| 308 | if (!label || !description) { | |
| 309 | return undefined; | |
| 310 | } | |
| 311 | return { label, description }; | |
| 312 | }) | |
| 313 | .filter((option): option is { label: string; description: string } => option !== undefined); | |
| 314 | const id = asString(question.id)?.trim(); | |
| 315 | const header = asString(question.header)?.trim(); | |
| 316 | const prompt = asString(question.question)?.trim(); | |
| 317 | if (!id || !header || !prompt || !options || options.length === 0) { | |
| 318 | return undefined; | |
| 319 | } | |
| 320 | return { | |
| 321 | id, | |
| 322 | header, | |
| 323 | question: prompt, | |
| 324 | options, | |
| 325 | }; | |
| 326 | }) | |
| 327 | .filter( | |
| 328 | ( | |
| 329 | question, | |
| 330 | ): question is { | |
| 331 | id: string; | |
| 332 | header: string; | |
| 333 | question: string; | |
| 334 | options: Array<{ label: string; description: string }>; | |
| 335 | } => question !== undefined, | |
| 336 | ); | |
| 337 | ||
| 338 | return parsedQuestions.length > 0 ? parsedQuestions : undefined; | |
| 339 | } | |
| 340 | ||
| 252 | 341 | function toThreadState( |
| 253 | 342 | value: unknown, |
| 254 | 343 | ): "active" | "idle" | "archived" | "closed" | "compacted" | "error" { |
| ... | ... | @@ -304,6 +393,46 @@ function asRuntimeRequestId(requestId: string): RuntimeRequestId { |
| 304 | 393 | return RuntimeRequestId.makeUnsafe(requestId); |
| 305 | 394 | } |
| 306 | 395 | |
| 396 | function asRuntimeTaskId(taskId: string): RuntimeTaskId { | |
| 397 | return RuntimeTaskId.makeUnsafe(taskId); | |
| 398 | } | |
| 399 | ||
| 400 | function codexEventMessage(payload: Record<string, unknown> | undefined): Record<string, unknown> | undefined { | |
| 401 | return asObject(payload?.msg); | |
| 402 | } | |
| 403 | ||
| 404 | function codexEventBase( | |
| 405 | event: ProviderEvent, | |
| 406 | canonicalThreadId: ThreadId, | |
| 407 | ): Omit<ProviderRuntimeEvent, "type" | "payload"> { | |
| 408 | const payload = asObject(event.payload); | |
| 409 | const msg = codexEventMessage(payload); | |
| 410 | const turnId = asString(msg?.turn_id) ?? asString(msg?.turnId); | |
| 411 | const itemId = asString(msg?.item_id) ?? asString(msg?.itemId); | |
| 412 | const requestId = asString(msg?.request_id) ?? asString(msg?.requestId); | |
| 413 | const base = runtimeEventBase(event, canonicalThreadId); | |
| 414 | const providerRefs = base.providerRefs | |
| 415 | ? { | |
| 416 | ...base.providerRefs, | |
| 417 | ...(turnId ? { providerTurnId: turnId } : {}), | |
| 418 | ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 419 | ...(requestId ? { providerRequestId: requestId } : {}), | |
| 420 | } | |
| 421 | : { | |
| 422 | ...(turnId ? { providerTurnId: turnId } : {}), | |
| 423 | ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 424 | ...(requestId ? { providerRequestId: requestId } : {}), | |
| 425 | }; | |
| 426 | ||
| 427 | return { | |
| 428 | ...base, | |
| 429 | ...(turnId ? { turnId: TurnId.makeUnsafe(turnId) } : {}), | |
| 430 | ...(itemId ? { itemId: asRuntimeItemId(ProviderItemId.makeUnsafe(itemId)) } : {}), | |
| 431 | ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}), | |
| 432 | ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}), | |
| 433 | }; | |
| 434 | } | |
| 435 | ||
| 307 | 436 | function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent["raw"]>["source"] { |
| 308 | 437 | return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification"; |
| 309 | 438 | } |
| ... | ... | @@ -404,6 +533,22 @@ function mapToRuntimeEvents( |
| 404 | 533 | } |
| 405 | 534 | |
| 406 | 535 | if (event.kind === "request") { |
| 536 | if (event.method === "item/tool/requestUserInput") { | |
| 537 | const questions = toUserInputQuestions(payload); | |
| 538 | if (!questions) { | |
| 539 | return []; | |
| 540 | } | |
| 541 | return [ | |
| 542 | { | |
| 543 | ...runtimeEventBase(event, canonicalThreadId), | |
| 544 | type: "user-input.requested", | |
| 545 | payload: { | |
| 546 | questions, | |
| 547 | }, | |
| 548 | }, | |
| 549 | ]; | |
| 550 | } | |
| 551 | ||
| 407 | 552 | const detail = |
| 408 | 553 | asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt); |
| 409 | 554 | return [ |
| ... | ... | @@ -737,6 +882,102 @@ function mapToRuntimeEvents( |
| 737 | 882 | ]; |
| 738 | 883 | } |
| 739 | 884 | |
| 885 | if (event.method === "item/tool/requestUserInput/answered") { | |
| 886 | return [ | |
| 887 | { | |
| 888 | ...runtimeEventBase(event, canonicalThreadId), | |
| 889 | type: "user-input.resolved", | |
| 890 | payload: { | |
| 891 | answers: toCanonicalUserInputAnswers( | |
| 892 | asObject(event.payload)?.answers as ProviderUserInputAnswers | undefined, | |
| 893 | ), | |
| 894 | }, | |
| 895 | }, | |
| 896 | ]; | |
| 897 | } | |
| 898 | ||
| 899 | if (event.method === "codex/event/task_started") { | |
| 900 | const msg = codexEventMessage(payload); | |
| 901 | const taskId = asString(payload?.id) ?? asString(msg?.turn_id); | |
| 902 | if (!taskId) { | |
| 903 | return []; | |
| 904 | } | |
| 905 | return [ | |
| 906 | { | |
| 907 | ...codexEventBase(event, canonicalThreadId), | |
| 908 | type: "task.started", | |
| 909 | payload: { | |
| 910 | taskId: asRuntimeTaskId(taskId), | |
| 911 | ...(asString(msg?.collaboration_mode_kind) | |
| 912 | ? { taskType: asString(msg?.collaboration_mode_kind) } | |
| 913 | : {}), | |
| 914 | }, | |
| 915 | }, | |
| 916 | ]; | |
| 917 | } | |
| 918 | ||
| 919 | if (event.method === "codex/event/task_complete") { | |
| 920 | const msg = codexEventMessage(payload); | |
| 921 | const taskId = asString(payload?.id) ?? asString(msg?.turn_id); | |
| 922 | if (!taskId) { | |
| 923 | return []; | |
| 924 | } | |
| 925 | return [ | |
| 926 | { | |
| 927 | ...codexEventBase(event, canonicalThreadId), | |
| 928 | type: "task.completed", | |
| 929 | payload: { | |
| 930 | taskId: asRuntimeTaskId(taskId), | |
| 931 | status: "completed", | |
| 932 | ...(asString(msg?.last_agent_message) | |
| 933 | ? { summary: asString(msg?.last_agent_message) } | |
| 934 | : {}), | |
| 935 | }, | |
| 936 | }, | |
| 937 | ]; | |
| 938 | } | |
| 939 | ||
| 940 | if (event.method === "codex/event/agent_reasoning") { | |
| 941 | const msg = codexEventMessage(payload); | |
| 942 | const taskId = asString(payload?.id); | |
| 943 | const description = asString(msg?.text); | |
| 944 | if (!taskId || !description) { | |
| 945 | return []; | |
| 946 | } | |
| 947 | return [ | |
| 948 | { | |
| 949 | ...codexEventBase(event, canonicalThreadId), | |
| 950 | type: "task.progress", | |
| 951 | payload: { | |
| 952 | taskId: asRuntimeTaskId(taskId), | |
| 953 | description, | |
| 954 | }, | |
| 955 | }, | |
| 956 | ]; | |
| 957 | } | |
| 958 | ||
| 959 | if (event.method === "codex/event/reasoning_content_delta") { | |
| 960 | const msg = codexEventMessage(payload); | |
| 961 | const delta = asString(msg?.delta); | |
| 962 | if (!delta) { | |
| 963 | return []; | |
| 964 | } | |
| 965 | return [ | |
| 966 | { | |
| 967 | ...codexEventBase(event, canonicalThreadId), | |
| 968 | type: "content.delta", | |
| 969 | payload: { | |
| 970 | streamKind: | |
| 971 | asNumber(msg?.summary_index) !== undefined ? "reasoning_summary_text" : "reasoning_text", | |
| 972 | delta, | |
| 973 | ...(asNumber(msg?.summary_index) !== undefined | |
| 974 | ? { summaryIndex: asNumber(msg?.summary_index) } | |
| 975 | : {}), | |
| 976 | }, | |
| 977 | }, | |
| 978 | ]; | |
| 979 | } | |
| 980 | ||
| 740 | 981 | if (event.method === "model/rerouted") { |
| 741 | 982 | return [ |
| 742 | 983 | { |
| ... | ... | @@ -1034,6 +1275,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1034 | 1275 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1035 | 1276 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 1036 | 1277 | ...(input.effort !== undefined ? { effort: input.effort } : {}), |
| 1278 | ...(input.interactionMode !== undefined | |
| 1279 | ? { interactionMode: input.interactionMode } | |
| 1280 | : {}), | |
| 1037 | 1281 | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), |
| 1038 | 1282 | }; |
| 1039 | 1283 | return manager.sendTurn(managerInput); |
| ... | ... | @@ -1096,6 +1340,16 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1096 | 1340 | catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), |
| 1097 | 1341 | }); |
| 1098 | 1342 | |
| 1343 | const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( | |
| 1344 | threadId, | |
| 1345 | requestId, | |
| 1346 | answers, | |
| 1347 | ) => | |
| 1348 | Effect.tryPromise({ | |
| 1349 | try: () => manager.respondToUserInput(threadId, requestId, answers), | |
| 1350 | catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), | |
| 1351 | }); | |
| 1352 | ||
| 1099 | 1353 | const stopSession: CodexAdapterShape["stopSession"] = (threadId) => |
| 1100 | 1354 | Effect.sync(() => { |
| 1101 | 1355 | manager.stopSession(threadId); |
| ... | ... | @@ -1163,6 +1417,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1163 | 1417 | readThread, |
| 1164 | 1418 | rollbackThread, |
| 1165 | 1419 | respondToRequest, |
| 1420 | respondToUserInput, | |
| 1166 | 1421 | stopSession, |
| 1167 | 1422 | listSessions, |
| 1168 | 1423 | hasSession, |
apps/server/src/provider/Layers/CursorAdapter.ts+14| ... | ... | @@ -1490,6 +1490,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1490 | 1490 | }); |
| 1491 | 1491 | }); |
| 1492 | 1492 | |
| 1493 | const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( | |
| 1494 | threadId, | |
| 1495 | requestId, | |
| 1496 | _answers, | |
| 1497 | ) => | |
| 1498 | Effect.fail( | |
| 1499 | new ProviderAdapterRequestError({ | |
| 1500 | provider: PROVIDER, | |
| 1501 | method: "cursor/ask_question", | |
| 1502 | detail: `Cursor does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, | |
| 1503 | }), | |
| 1504 | ); | |
| 1505 | ||
| 1493 | 1506 | const stopSession: CursorAdapterShape["stopSession"] = (threadId) => |
| 1494 | 1507 | Effect.gen(function* () { |
| 1495 | 1508 | const context = yield* requireSession(threadId); |
| ... | ... | @@ -1539,6 +1552,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1539 | 1552 | readThread, |
| 1540 | 1553 | rollbackThread, |
| 1541 | 1554 | respondToRequest, |
| 1555 | respondToUserInput, | |
| 1542 | 1556 | stopSession, |
| 1543 | 1557 | listSessions, |
| 1544 | 1558 | hasSession, |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+3| ... | ... | @@ -19,6 +19,7 @@ const fakeCodexAdapter: CodexAdapterShape = { |
| 19 | 19 | sendTurn: vi.fn(), |
| 20 | 20 | interruptTurn: vi.fn(), |
| 21 | 21 | respondToRequest: vi.fn(), |
| 22 | respondToUserInput: vi.fn(), | |
| 22 | 23 | stopSession: vi.fn(), |
| 23 | 24 | listSessions: vi.fn(), |
| 24 | 25 | hasSession: vi.fn(), |
| ... | ... | @@ -35,6 +36,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = { |
| 35 | 36 | sendTurn: vi.fn(), |
| 36 | 37 | interruptTurn: vi.fn(), |
| 37 | 38 | respondToRequest: vi.fn(), |
| 39 | respondToUserInput: vi.fn(), | |
| 38 | 40 | stopSession: vi.fn(), |
| 39 | 41 | listSessions: vi.fn(), |
| 40 | 42 | hasSession: vi.fn(), |
| ... | ... | @@ -51,6 +53,7 @@ const fakeCursorAdapter: CursorAdapterShape = { |
| 51 | 53 | sendTurn: vi.fn(), |
| 52 | 54 | interruptTurn: vi.fn(), |
| 53 | 55 | respondToRequest: vi.fn(), |
| 56 | respondToUserInput: vi.fn(), | |
| 54 | 57 | stopSession: vi.fn(), |
| 55 | 58 | listSessions: vi.fn(), |
| 56 | 59 | hasSession: vi.fn(), |
apps/server/src/provider/Layers/ProviderService.test.ts+36-7| ... | ... | @@ -116,6 +116,14 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 116 | 116 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void, |
| 117 | 117 | ); |
| 118 | 118 | |
| 119 | const respondToUserInput = vi.fn( | |
| 120 | ( | |
| 121 | _threadId: ThreadId, | |
| 122 | _requestId: string, | |
| 123 | _answers: Record<string, unknown>, | |
| 124 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void, | |
| 125 | ); | |
| 126 | ||
| 119 | 127 | const stopSession = vi.fn( |
| 120 | 128 | (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> => |
| 121 | 129 | Effect.sync(() => { |
| ... | ... | @@ -172,6 +180,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 172 | 180 | sendTurn, |
| 173 | 181 | interruptTurn, |
| 174 | 182 | respondToRequest, |
| 183 | respondToUserInput, | |
| 175 | 184 | stopSession, |
| 176 | 185 | listSessions, |
| 177 | 186 | hasSession, |
| ... | ... | @@ -192,6 +201,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 192 | 201 | sendTurn, |
| 193 | 202 | interruptTurn, |
| 194 | 203 | respondToRequest, |
| 204 | respondToUserInput, | |
| 195 | 205 | stopSession, |
| 196 | 206 | listSessions, |
| 197 | 207 | hasSession, |
| ... | ... | @@ -395,12 +405,12 @@ it.effect( |
| 395 | 405 | provider?: string; |
| 396 | 406 | cwd?: string; |
| 397 | 407 | resumeCursor?: unknown; |
| 398 | resumeThreadId?: string; | |
| 408 | threadId?: string; | |
| 399 | 409 | }; |
| 400 | 410 | assert.equal(startPayload.provider, "codex"); |
| 401 | 411 | assert.equal(startPayload.cwd, "/tmp/project"); |
| 402 | 412 | assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); |
| 403 | assert.equal(startPayload.resumeThreadId, "thread-1"); | |
| 413 | assert.equal(startPayload.threadId, startedSession.threadId); | |
| 404 | 414 | } |
| 405 | 415 | assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); |
| 406 | 416 | const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; |
| ... | ... | @@ -446,6 +456,23 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 446 | 456 | [session.threadId, asRequestId("req-1"), "accept"], |
| 447 | 457 | ]); |
| 448 | 458 | |
| 459 | yield* provider.respondToUserInput({ | |
| 460 | threadId: session.threadId, | |
| 461 | requestId: asRequestId("req-user-input-1"), | |
| 462 | answers: { | |
| 463 | sandbox_mode: "workspace-write", | |
| 464 | }, | |
| 465 | }); | |
| 466 | assert.deepEqual(routing.codex.respondToUserInput.mock.calls, [ | |
| 467 | [ | |
| 468 | session.threadId, | |
| 469 | asRequestId("req-user-input-1"), | |
| 470 | { | |
| 471 | sandbox_mode: "workspace-write", | |
| 472 | }, | |
| 473 | ], | |
| 474 | ]); | |
| 475 | ||
| 449 | 476 | yield* provider.rollbackConversation({ |
| 450 | 477 | threadId: session.threadId, |
| 451 | 478 | numTurns: 0, |
| ... | ... | @@ -519,12 +546,12 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 519 | 546 | provider?: string; |
| 520 | 547 | cwd?: string; |
| 521 | 548 | resumeCursor?: unknown; |
| 522 | resumeThreadId?: string; | |
| 549 | threadId?: string; | |
| 523 | 550 | }; |
| 524 | 551 | assert.equal(startPayload.provider, "codex"); |
| 525 | 552 | assert.equal(startPayload.cwd, "/tmp/project"); |
| 526 | 553 | assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); |
| 527 | assert.equal(startPayload.resumeThreadId, "thread-2"); | |
| 554 | assert.equal(startPayload.threadId, initial.threadId); | |
| 528 | 555 | } |
| 529 | 556 | assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); |
| 530 | 557 | const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; |
| ... | ... | @@ -561,12 +588,12 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 561 | 588 | provider?: string; |
| 562 | 589 | cwd?: string; |
| 563 | 590 | resumeCursor?: unknown; |
| 564 | resumeThreadId?: string; | |
| 591 | threadId?: string; | |
| 565 | 592 | }; |
| 566 | 593 | assert.equal(startPayload.provider, "codex"); |
| 567 | 594 | assert.equal(startPayload.cwd, "/tmp/project-send-turn"); |
| 568 | 595 | assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); |
| 569 | assert.equal(startPayload.resumeThreadId, "thread-4"); | |
| 596 | assert.equal(startPayload.threadId, initial.threadId); | |
| 570 | 597 | } |
| 571 | 598 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); |
| 572 | 599 | }), |
| ... | ... | @@ -629,7 +656,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 629 | 656 | }; |
| 630 | 657 | assert.equal(runtimePayload.cwd, process.cwd()); |
| 631 | 658 | assert.equal(runtimePayload.model, null); |
| 632 | assert.equal(runtimePayload.activeTurnId, "turn-1"); | |
| 659 | assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); | |
| 633 | 660 | assert.equal(runtimePayload.lastError, null); |
| 634 | 661 | assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); |
| 635 | 662 | } |
| ... | ... | @@ -822,7 +849,9 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 822 | 849 | |
| 823 | 850 | const failure = yield* Effect.result( |
| 824 | 851 | provider.startSession(asThreadId("thread-validation"), { |
| 852 | threadId: asThreadId("thread-validation"), | |
| 825 | 853 | provider: "invalid-provider", |
| 854 | runtimeMode: "full-access", | |
| 826 | 855 | } as never), |
| 827 | 856 | ); |
| 828 | 857 |
apps/server/src/provider/Layers/ProviderService.ts+17| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | ThreadId, |
| 15 | 15 | ProviderInterruptTurnInput, |
| 16 | 16 | ProviderRespondToRequestInput, |
| 17 | ProviderRespondToUserInputInput, | |
| 17 | 18 | ProviderSendTurnInput, |
| 18 | 19 | ProviderSessionStartInput, |
| 19 | 20 | ProviderStopSessionInput, |
| ... | ... | @@ -333,6 +334,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 333 | 334 | yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); |
| 334 | 335 | }); |
| 335 | 336 | |
| 337 | const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) => | |
| 338 | Effect.gen(function* () { | |
| 339 | const input = yield* decodeInputOrValidationError({ | |
| 340 | operation: "ProviderService.respondToUserInput", | |
| 341 | schema: ProviderRespondToUserInputInput, | |
| 342 | payload: rawInput, | |
| 343 | }); | |
| 344 | const routed = yield* resolveRoutableSession({ | |
| 345 | threadId: input.threadId, | |
| 346 | operation: "ProviderService.respondToUserInput", | |
| 347 | allowRecovery: true, | |
| 348 | }); | |
| 349 | yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); | |
| 350 | }); | |
| 351 | ||
| 336 | 352 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => |
| 337 | 353 | Effect.gen(function* () { |
| 338 | 354 | const input = yield* decodeInputOrValidationError({ |
| ... | ... | @@ -442,6 +458,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 442 | 458 | sendTurn, |
| 443 | 459 | interruptTurn, |
| 444 | 460 | respondToRequest, |
| 461 | respondToUserInput, | |
| 445 | 462 | stopSession, |
| 446 | 463 | listSessions, |
| 447 | 464 | getCapabilities, |
apps/server/src/provider/Services/ProviderAdapter.ts+10| ... | ... | @@ -11,6 +11,7 @@ import type { |
| 11 | 11 | ApprovalRequestId, |
| 12 | 12 | ProviderApprovalDecision, |
| 13 | 13 | ProviderKind, |
| 14 | ProviderUserInputAnswers, | |
| 14 | 15 | ProviderRuntimeEvent, |
| 15 | 16 | ProviderSendTurnInput, |
| 16 | 17 | ProviderSession, |
| ... | ... | @@ -79,6 +80,15 @@ export interface ProviderAdapterShape<TError> { |
| 79 | 80 | decision: ProviderApprovalDecision, |
| 80 | 81 | ) => Effect.Effect<void, TError>; |
| 81 | 82 | |
| 83 | /** | |
| 84 | * Respond to a structured user-input request. | |
| 85 | */ | |
| 86 | readonly respondToUserInput: ( | |
| 87 | threadId: ThreadId, | |
| 88 | requestId: ApprovalRequestId, | |
| 89 | answers: ProviderUserInputAnswers, | |
| 90 | ) => Effect.Effect<void, TError>; | |
| 91 | ||
| 82 | 92 | /** |
| 83 | 93 | * Stop one provider session. |
| 84 | 94 | */ |
apps/server/src/provider/Services/ProviderService.ts+8| ... | ... | @@ -15,6 +15,7 @@ import type { |
| 15 | 15 | ProviderInterruptTurnInput, |
| 16 | 16 | ProviderKind, |
| 17 | 17 | ProviderRespondToRequestInput, |
| 18 | ProviderRespondToUserInputInput, | |
| 18 | 19 | ProviderRuntimeEvent, |
| 19 | 20 | ProviderSendTurnInput, |
| 20 | 21 | ProviderSession, |
| ... | ... | @@ -62,6 +63,13 @@ export interface ProviderServiceShape { |
| 62 | 63 | input: ProviderRespondToRequestInput, |
| 63 | 64 | ) => Effect.Effect<void, ProviderServiceError>; |
| 64 | 65 | |
| 66 | /** | |
| 67 | * Respond to a provider structured user-input request. | |
| 68 | */ | |
| 69 | readonly respondToUserInput: ( | |
| 70 | input: ProviderRespondToUserInputInput, | |
| 71 | ) => Effect.Effect<void, ProviderServiceError>; | |
| 72 | ||
| 65 | 73 | /** |
| 66 | 74 | * Stop a provider session. |
| 67 | 75 | */ |
apps/server/src/wsServer.test.ts+54| ... | ... | @@ -1126,6 +1126,8 @@ describe("WebSocket Server", () => { |
| 1126 | 1126 | projectId: "project-diff", |
| 1127 | 1127 | title: "Diff Thread", |
| 1128 | 1128 | model: "gpt-5-codex", |
| 1129 | runtimeMode: "full-access", | |
| 1130 | interactionMode: "default", | |
| 1129 | 1131 | branch: null, |
| 1130 | 1132 | worktreePath: null, |
| 1131 | 1133 | createdAt, |
| ... | ... | @@ -1164,6 +1166,7 @@ describe("WebSocket Server", () => { |
| 1164 | 1166 | }), |
| 1165 | 1167 | interruptTurn: () => unsupported(), |
| 1166 | 1168 | respondToRequest: () => unsupported(), |
| 1169 | respondToUserInput: () => unsupported(), | |
| 1167 | 1170 | stopSession: () => unsupported(), |
| 1168 | 1171 | listSessions: () => Effect.succeed([]), |
| 1169 | 1172 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| ... | ... | @@ -1202,6 +1205,8 @@ describe("WebSocket Server", () => { |
| 1202 | 1205 | projectId: "project-1", |
| 1203 | 1206 | title: "Thread 1", |
| 1204 | 1207 | model: "gpt-5-codex", |
| 1208 | runtimeMode: "full-access", | |
| 1209 | interactionMode: "default", | |
| 1205 | 1210 | branch: null, |
| 1206 | 1211 | worktreePath: null, |
| 1207 | 1212 | createdAt, |
| ... | ... | @@ -1220,6 +1225,7 @@ describe("WebSocket Server", () => { |
| 1220 | 1225 | }, |
| 1221 | 1226 | assistantDeliveryMode: "streaming", |
| 1222 | 1227 | runtimeMode: "approval-required", |
| 1228 | interactionMode: "default", | |
| 1223 | 1229 | createdAt, |
| 1224 | 1230 | }); |
| 1225 | 1231 | expect(startTurnResponse.error).toBeUndefined(); |
| ... | ... | @@ -1519,6 +1525,54 @@ describe("WebSocket Server", () => { |
| 1519 | 1525 | }); |
| 1520 | 1526 | }); |
| 1521 | 1527 | |
| 1528 | it("supports projects.writeFile within the workspace root", async () => { | |
| 1529 | const workspace = makeTempDir("t3code-ws-write-file-"); | |
| 1530 | ||
| 1531 | server = await createTestServer({ cwd: "/test" }); | |
| 1532 | const addr = server.address(); | |
| 1533 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1534 | ||
| 1535 | const ws = await connectWs(port); | |
| 1536 | connections.push(ws); | |
| 1537 | await waitForMessage(ws); | |
| 1538 | ||
| 1539 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | |
| 1540 | cwd: workspace, | |
| 1541 | relativePath: "plans/effect-rpc.md", | |
| 1542 | contents: "# Plan\n\n- step 1\n", | |
| 1543 | }); | |
| 1544 | ||
| 1545 | expect(response.error).toBeUndefined(); | |
| 1546 | expect(response.result).toEqual({ | |
| 1547 | relativePath: "plans/effect-rpc.md", | |
| 1548 | }); | |
| 1549 | expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe( | |
| 1550 | "# Plan\n\n- step 1\n", | |
| 1551 | ); | |
| 1552 | }); | |
| 1553 | ||
| 1554 | it("rejects projects.writeFile paths outside the workspace root", async () => { | |
| 1555 | const workspace = makeTempDir("t3code-ws-write-file-reject-"); | |
| 1556 | ||
| 1557 | server = await createTestServer({ cwd: "/test" }); | |
| 1558 | const addr = server.address(); | |
| 1559 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1560 | ||
| 1561 | const ws = await connectWs(port); | |
| 1562 | connections.push(ws); | |
| 1563 | await waitForMessage(ws); | |
| 1564 | ||
| 1565 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | |
| 1566 | cwd: workspace, | |
| 1567 | relativePath: "../escape.md", | |
| 1568 | contents: "# no\n", | |
| 1569 | }); | |
| 1570 | ||
| 1571 | expect(response.result).toBeUndefined(); | |
| 1572 | expect(response.error?.message).toContain("Workspace file path must stay within the project root."); | |
| 1573 | expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); | |
| 1574 | }); | |
| 1575 | ||
| 1522 | 1576 | it("routes git core methods over websocket", async () => { |
| 1523 | 1577 | const listBranches = vi.fn(() => |
| 1524 | 1578 | Effect.succeed({ |
apps/server/src/wsServer.ts+70| ... | ... | @@ -12,6 +12,7 @@ import type { Duplex } from "node:stream"; |
| 12 | 12 | import Mime from "@effect/platform-node/Mime"; |
| 13 | 13 | import { |
| 14 | 14 | CommandId, |
| 15 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 15 | 16 | type ClientOrchestrationCommand, |
| 16 | 17 | type OrchestrationCommand, |
| 17 | 18 | ORCHESTRATION_WS_CHANNELS, |
| ... | ... | @@ -146,6 +147,48 @@ function websocketRawToString(raw: unknown): string | null { |
| 146 | 147 | return null; |
| 147 | 148 | } |
| 148 | 149 | |
| 150 | function toPosixRelativePath(input: string): string { | |
| 151 | return input.replaceAll("\\", "/"); | |
| 152 | } | |
| 153 | ||
| 154 | function resolveWorkspaceWritePath(params: { | |
| 155 | workspaceRoot: string; | |
| 156 | relativePath: string; | |
| 157 | path: Path.Path; | |
| 158 | }): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { | |
| 159 | const normalizedInputPath = params.relativePath.trim(); | |
| 160 | if (params.path.isAbsolute(normalizedInputPath)) { | |
| 161 | return Effect.fail( | |
| 162 | new RouteRequestError({ | |
| 163 | message: "Workspace file path must be relative to the project root.", | |
| 164 | }), | |
| 165 | ); | |
| 166 | } | |
| 167 | ||
| 168 | const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); | |
| 169 | const relativeToRoot = toPosixRelativePath( | |
| 170 | params.path.relative(params.workspaceRoot, absolutePath), | |
| 171 | ); | |
| 172 | if ( | |
| 173 | relativeToRoot.length === 0 || | |
| 174 | relativeToRoot === "." || | |
| 175 | relativeToRoot.startsWith("../") || | |
| 176 | relativeToRoot === ".." || | |
| 177 | params.path.isAbsolute(relativeToRoot) | |
| 178 | ) { | |
| 179 | return Effect.fail( | |
| 180 | new RouteRequestError({ | |
| 181 | message: "Workspace file path must stay within the project root.", | |
| 182 | }), | |
| 183 | ); | |
| 184 | } | |
| 185 | ||
| 186 | return Effect.succeed({ | |
| 187 | absolutePath, | |
| 188 | relativePath: relativeToRoot, | |
| 189 | }); | |
| 190 | } | |
| 191 | ||
| 149 | 192 | function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 150 | 193 | return Struct.omit(body, ["_tag"]); |
| 151 | 194 | } |
| ... | ... | @@ -595,6 +638,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 595 | 638 | projectId: bootstrapProjectId, |
| 596 | 639 | title: "New thread", |
| 597 | 640 | model: bootstrapProjectDefaultModel, |
| 641 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 598 | 642 | runtimeMode: "full-access", |
| 599 | 643 | branch: null, |
| 600 | 644 | worktreePath: null, |
| ... | ... | @@ -684,6 +728,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 684 | 728 | }); |
| 685 | 729 | } |
| 686 | 730 | |
| 731 | case WS_METHODS.projectsWriteFile: { | |
| 732 | const body = stripRequestTag(request.body); | |
| 733 | const target = yield* resolveWorkspaceWritePath({ | |
| 734 | workspaceRoot: body.cwd, | |
| 735 | relativePath: body.relativePath, | |
| 736 | path, | |
| 737 | }); | |
| 738 | yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( | |
| 739 | Effect.mapError( | |
| 740 | (cause) => | |
| 741 | new RouteRequestError({ | |
| 742 | message: `Failed to prepare workspace path: ${String(cause)}`, | |
| 743 | }), | |
| 744 | ), | |
| 745 | ); | |
| 746 | yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( | |
| 747 | Effect.mapError( | |
| 748 | (cause) => | |
| 749 | new RouteRequestError({ | |
| 750 | message: `Failed to write workspace file: ${String(cause)}`, | |
| 751 | }), | |
| 752 | ), | |
| 753 | ); | |
| 754 | return { relativePath: target.relativePath }; | |
| 755 | } | |
| 756 | ||
| 687 | 757 | case WS_METHODS.shellOpenInEditor: { |
| 688 | 758 | const body = stripRequestTag(request.body); |
| 689 | 759 | return yield* openInEditor(body); |
apps/web/src/components/ChatView.tsx+1245-141| ... | ... | @@ -41,6 +41,7 @@ import { |
| 41 | 41 | useRef, |
| 42 | 42 | useState, |
| 43 | 43 | useTransition, |
| 44 | useId, | |
| 44 | 45 | } from "react"; |
| 45 | 46 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 46 | 47 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| ... | ... | @@ -57,17 +58,22 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer |
| 57 | 58 | import { isElectron } from "../env"; |
| 58 | 59 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 59 | 60 | import { |
| 61 | type ComposerSlashCommand, | |
| 60 | 62 | type ComposerTrigger, |
| 61 | 63 | type ComposerTriggerKind, |
| 62 | 64 | detectComposerTrigger, |
| 63 | 65 | expandCollapsedComposerCursor, |
| 66 | parseStandaloneComposerSlashCommand, | |
| 64 | 67 | replaceTextRange, |
| 65 | 68 | } from "../composer-logic"; |
| 66 | 69 | import { |
| 67 | 70 | derivePendingApprovals, |
| 71 | derivePendingUserInputs, | |
| 68 | 72 | derivePhase, |
| 69 | 73 | deriveTimelineEntries, |
| 74 | deriveActivePlanState, | |
| 70 | 75 | type PendingApproval, |
| 76 | type PendingUserInput, | |
| 71 | 77 | PROVIDER_OPTIONS, |
| 72 | 78 | deriveWorkLogEntries, |
| 73 | 79 | hasToolActivityForTurn, |
| ... | ... | @@ -76,9 +82,24 @@ import { |
| 76 | 82 | formatTimestamp, |
| 77 | 83 | } from "../session-logic"; |
| 78 | 84 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; |
| 85 | import { | |
| 86 | buildPendingUserInputAnswers, | |
| 87 | derivePendingUserInputProgress, | |
| 88 | setPendingUserInputCustomAnswer, | |
| 89 | type PendingUserInputDraftAnswer, | |
| 90 | } from "../pendingUserInput"; | |
| 79 | 91 | import { useStore } from "../store"; |
| 92 | import { | |
| 93 | buildPlanImplementationThreadTitle, | |
| 94 | buildPlanImplementationPrompt, | |
| 95 | buildProposedPlanMarkdownFilename, | |
| 96 | findLatestProposedPlanMessage, | |
| 97 | parseProposedPlanMessage, | |
| 98 | proposedPlanTitle, | |
| 99 | } from "../proposedPlan"; | |
| 80 | 100 | import { truncateTitle } from "../truncateTitle"; |
| 81 | 101 | import { |
| 102 | DEFAULT_INTERACTION_MODE, | |
| 82 | 103 | DEFAULT_RUNTIME_MODE, |
| 83 | 104 | DEFAULT_THREAD_TERMINAL_ID, |
| 84 | 105 | MAX_THREAD_TERMINAL_COUNT, |
| ... | ... | @@ -114,6 +135,7 @@ import { |
| 114 | 135 | FileIcon, |
| 115 | 136 | FolderIcon, |
| 116 | 137 | DiffIcon, |
| 138 | EllipsisIcon, | |
| 117 | 139 | FolderClosedIcon, |
| 118 | 140 | InfoIcon, |
| 119 | 141 | LockIcon, |
| ... | ... | @@ -124,13 +146,8 @@ import { |
| 124 | 146 | CheckIcon, |
| 125 | 147 | } from "lucide-react"; |
| 126 | 148 | import { Button } from "./ui/button"; |
| 127 | import { | |
| 128 | Select, | |
| 129 | SelectItem, | |
| 130 | SelectPopup, | |
| 131 | SelectTrigger, | |
| 132 | SelectValue, | |
| 133 | } from "./ui/select"; | |
| 149 | import { Input } from "./ui/input"; | |
| 150 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 134 | 151 | import { Separator } from "./ui/separator"; |
| 135 | 152 | import { Group, GroupSeparator } from "./ui/group"; |
| 136 | 153 | import { |
| ... | ... | @@ -161,6 +178,16 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 161 | 178 | import { Badge } from "./ui/badge"; |
| 162 | 179 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 163 | 180 | import { Command, CommandItem, CommandList } from "./ui/command"; |
| 181 | import { | |
| 182 | Dialog, | |
| 183 | DialogDescription, | |
| 184 | DialogFooter, | |
| 185 | DialogHeader, | |
| 186 | DialogPanel, | |
| 187 | DialogPopup, | |
| 188 | DialogTitle, | |
| 189 | } from "./ui/dialog"; | |
| 190 | import { toastManager } from "./ui/toast"; | |
| 164 | 191 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 165 | 192 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 166 | 193 | import { |
| ... | ... | @@ -172,7 +199,7 @@ import { |
| 172 | 199 | } from "~/projectScripts"; |
| 173 | 200 | import { Toggle } from "./ui/toggle"; |
| 174 | 201 | import { SidebarTrigger } from "./ui/sidebar"; |
| 175 | import { newCommandId, newMessageId } from "~/lib/utils"; | |
| 202 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; | |
| 176 | 203 | import { readNativeApi } from "~/nativeApi"; |
| 177 | 204 | import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 178 | 205 | import { |
| ... | ... | @@ -206,6 +233,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 206 | 233 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 207 | 234 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 208 | 235 | const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; |
| 236 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | |
| 209 | 237 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 210 | 238 | const SCRIPT_TERMINAL_COLS = 120; |
| 211 | 239 | const SCRIPT_TERMINAL_ROWS = 30; |
| ... | ... | @@ -236,6 +264,22 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 236 | 264 | return "text-muted-foreground/40"; |
| 237 | 265 | } |
| 238 | 266 | |
| 267 | function normalizePlanMarkdownForExport(planMarkdown: string): string { | |
| 268 | return `${planMarkdown.trimEnd()}\n`; | |
| 269 | } | |
| 270 | ||
| 271 | function downloadTextFile(filename: string, contents: string): void { | |
| 272 | const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); | |
| 273 | const url = URL.createObjectURL(blob); | |
| 274 | const anchor = document.createElement("a"); | |
| 275 | anchor.href = url; | |
| 276 | anchor.download = filename; | |
| 277 | anchor.click(); | |
| 278 | window.setTimeout(() => { | |
| 279 | URL.revokeObjectURL(url); | |
| 280 | }, 0); | |
| 281 | } | |
| 282 | ||
| 239 | 283 | interface ExpandedImageItem { |
| 240 | 284 | src: string; |
| 241 | 285 | name: string; |
| ... | ... | @@ -279,6 +323,7 @@ function buildLocalDraftThread( |
| 279 | 323 | title: "New thread", |
| 280 | 324 | model: fallbackModel, |
| 281 | 325 | runtimeMode: draftThread.runtimeMode, |
| 326 | interactionMode: draftThread.interactionMode, | |
| 282 | 327 | session: null, |
| 283 | 328 | messages: [], |
| 284 | 329 | error, |
| ... | ... | @@ -336,6 +381,7 @@ type ComposerCommandItem = |
| 336 | 381 | | { |
| 337 | 382 | id: string; |
| 338 | 383 | type: "slash-command"; |
| 384 | command: ComposerSlashCommand; | |
| 339 | 385 | label: string; |
| 340 | 386 | description: string; |
| 341 | 387 | } |
| ... | ... | @@ -513,6 +559,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 513 | 559 | const threads = useStore((store) => store.threads); |
| 514 | 560 | const projects = useStore((store) => store.projects); |
| 515 | 561 | const markThreadVisited = useStore((store) => store.markThreadVisited); |
| 562 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); | |
| 516 | 563 | const setStoreThreadError = useStore((store) => store.setError); |
| 517 | 564 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); |
| 518 | 565 | const { settings } = useAppSettings(); |
| ... | ... | @@ -560,6 +607,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 560 | 607 | const [isConnecting, _setIsConnecting] = useState(false); |
| 561 | 608 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 562 | 609 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 610 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< | |
| 611 | ApprovalRequestId[] | |
| 612 | >([]); | |
| 613 | const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< | |
| 614 | Record<string, Record<string, PendingUserInputDraftAnswer>> | |
| 615 | >({}); | |
| 616 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = | |
| 617 | useState<Record<string, number>>({}); | |
| 563 | 618 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 564 | 619 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 565 | 620 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| ... | ... | @@ -652,8 +707,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 652 | 707 | ); |
| 653 | 708 | const activeThread = serverThread ?? localDraftThread; |
| 654 | 709 | const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 710 | const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; | |
| 655 | 711 | const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode); |
| 712 | const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode); | |
| 656 | 713 | const [isRuntimeModePending, startRuntimeModeTransition] = useTransition(); |
| 714 | const [isInteractionModePending, startInteractionModeTransition] = useTransition(); | |
| 657 | 715 | const isServerThread = serverThread !== undefined; |
| 658 | 716 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 659 | 717 | const diffSearch = useMemo( |
| ... | ... | @@ -688,7 +746,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 688 | 746 | const selectedProviderByThreadId = composerDraft.provider; |
| 689 | 747 | const hasThreadStarted = Boolean( |
| 690 | 748 | activeThread && |
| 691 | (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null), | |
| 749 | (activeThread.latestTurn !== null || | |
| 750 | activeThread.messages.length > 0 || | |
| 751 | activeThread.session !== null), | |
| 692 | 752 | ); |
| 693 | 753 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 694 | 754 | ? (sessionProvider ?? selectedProviderByThreadId ?? null) |
| ... | ... | @@ -700,7 +760,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 700 | 760 | : null; |
| 701 | 761 | const baseThreadModel = resolveModelSlugForProvider( |
| 702 | 762 | selectedProvider, |
| 703 | activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, | |
| 763 | activeThread?.model ?? | |
| 764 | activeProject?.model ?? | |
| 765 | getDefaultModel(selectedProvider) ?? | |
| 766 | DEFAULT_MODEL, | |
| 704 | 767 | ); |
| 705 | 768 | const selectedModel = useMemo(() => { |
| 706 | 769 | const draftModel = composerDraft.model; |
| ... | ... | @@ -716,7 +779,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 716 | 779 | |
| 717 | 780 | const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider); |
| 718 | 781 | if (normalizedDraftModel) { |
| 719 | const normalizedMatch = providerOptions.find((option) => option.slug === normalizedDraftModel); | |
| 782 | const normalizedMatch = providerOptions.find( | |
| 783 | (option) => option.slug === normalizedDraftModel, | |
| 784 | ); | |
| 720 | 785 | if (normalizedMatch) { |
| 721 | 786 | return normalizedMatch.slug as ModelSlug; |
| 722 | 787 | } |
| ... | ... | @@ -732,19 +797,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 732 | 797 | [selectedModel, selectedProvider], |
| 733 | 798 | ); |
| 734 | 799 | const selectedCursorModelCapabilities = useMemo( |
| 735 | () => | |
| 736 | selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null, | |
| 800 | () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null), | |
| 737 | 801 | [selectedCursorModel], |
| 738 | 802 | ); |
| 739 | 803 | const hasSelectedCursorTraits = Boolean( |
| 740 | 804 | selectedCursorModelCapabilities && |
| 741 | (selectedCursorModelCapabilities.supportsReasoning || | |
| 742 | selectedCursorModelCapabilities.supportsFast || | |
| 743 | selectedCursorModelCapabilities.supportsThinking), | |
| 805 | (selectedCursorModelCapabilities.supportsReasoning || | |
| 806 | selectedCursorModelCapabilities.supportsFast || | |
| 807 | selectedCursorModelCapabilities.supportsThinking), | |
| 744 | 808 | ); |
| 745 | 809 | const selectedModelForPicker = |
| 746 | selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; | |
| 747 | const modelOptionsByProvider = useMemo(() => getCustomModelOptionsByProvider(settings), [settings]); | |
| 810 | selectedProvider === "cursor" && selectedCursorModel | |
| 811 | ? selectedCursorModel.family | |
| 812 | : selectedModel; | |
| 813 | const modelOptionsByProvider = useMemo( | |
| 814 | () => getCustomModelOptionsByProvider(settings), | |
| 815 | [settings], | |
| 816 | ); | |
| 748 | 817 | const selectedModelForPickerWithCustomFallback = useMemo(() => { |
| 749 | 818 | if (selectedProvider !== "cursor") { |
| 750 | 819 | const currentOptions = modelOptionsByProvider[selectedProvider]; |
| ... | ... | @@ -794,6 +863,80 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 794 | 863 | () => derivePendingApprovals(threadActivities), |
| 795 | 864 | [threadActivities], |
| 796 | 865 | ); |
| 866 | const pendingUserInputs = useMemo( | |
| 867 | () => derivePendingUserInputs(threadActivities), | |
| 868 | [threadActivities], | |
| 869 | ); | |
| 870 | const activePendingUserInput = pendingUserInputs[0] ?? null; | |
| 871 | const activePendingDraftAnswers = useMemo( | |
| 872 | () => | |
| 873 | activePendingUserInput | |
| 874 | ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? | |
| 875 | EMPTY_PENDING_USER_INPUT_ANSWERS) | |
| 876 | : EMPTY_PENDING_USER_INPUT_ANSWERS, | |
| 877 | [activePendingUserInput, pendingUserInputAnswersByRequestId], | |
| 878 | ); | |
| 879 | const activePendingQuestionIndex = activePendingUserInput | |
| 880 | ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) | |
| 881 | : 0; | |
| 882 | const activePendingProgress = useMemo( | |
| 883 | () => | |
| 884 | activePendingUserInput | |
| 885 | ? derivePendingUserInputProgress( | |
| 886 | activePendingUserInput.questions, | |
| 887 | activePendingDraftAnswers, | |
| 888 | activePendingQuestionIndex, | |
| 889 | ) | |
| 890 | : null, | |
| 891 | [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], | |
| 892 | ); | |
| 893 | const activePendingResolvedAnswers = useMemo( | |
| 894 | () => | |
| 895 | activePendingUserInput | |
| 896 | ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) | |
| 897 | : null, | |
| 898 | [activePendingDraftAnswers, activePendingUserInput], | |
| 899 | ); | |
| 900 | const activePendingIsResponding = activePendingUserInput | |
| 901 | ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) | |
| 902 | : false; | |
| 903 | const activeProposedPlan = useMemo(() => { | |
| 904 | if (!latestTurnSettled) { | |
| 905 | return null; | |
| 906 | } | |
| 907 | return findLatestProposedPlanMessage( | |
| 908 | activeThread?.messages ?? [], | |
| 909 | activeLatestTurn?.assistantMessageId ?? null, | |
| 910 | ); | |
| 911 | }, [activeLatestTurn?.assistantMessageId, activeThread?.messages, latestTurnSettled]); | |
| 912 | const activePlan = useMemo( | |
| 913 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), | |
| 914 | [activeLatestTurn?.turnId, threadActivities], | |
| 915 | ); | |
| 916 | const showPlanFollowUpPrompt = | |
| 917 | pendingUserInputs.length === 0 && | |
| 918 | optimisticInteractionMode === "plan" && | |
| 919 | latestTurnSettled && | |
| 920 | activeProposedPlan !== null; | |
| 921 | const hasComposerHeader = | |
| 922 | pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); | |
| 923 | useEffect(() => { | |
| 924 | if (!activePendingProgress) { | |
| 925 | return; | |
| 926 | } | |
| 927 | promptRef.current = activePendingProgress.customAnswer; | |
| 928 | setComposerCursor(activePendingProgress.customAnswer.length); | |
| 929 | setComposerTrigger( | |
| 930 | detectComposerTrigger( | |
| 931 | activePendingProgress.customAnswer, | |
| 932 | expandCollapsedComposerCursor( | |
| 933 | activePendingProgress.customAnswer, | |
| 934 | activePendingProgress.customAnswer.length, | |
| 935 | ), | |
| 936 | ), | |
| 937 | ); | |
| 938 | setComposerHighlightedItemId(null); | |
| 939 | }, [activePendingProgress, activePendingUserInput?.requestId]); | |
| 797 | 940 | useEffect(() => { |
| 798 | 941 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; |
| 799 | 942 | }, [attachmentPreviewHandoffByMessageId]); |
| ... | ... | @@ -1038,31 +1181,54 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1038 | 1181 | } |
| 1039 | 1182 | |
| 1040 | 1183 | if (composerTrigger.kind === "slash-command") { |
| 1041 | if (!"model".includes(composerTrigger.query.toLowerCase())) { | |
| 1042 | return []; | |
| 1043 | } | |
| 1044 | return [ | |
| 1184 | const slashCommandItems = [ | |
| 1045 | 1185 | { |
| 1046 | 1186 | id: "slash:model", |
| 1047 | 1187 | type: "slash-command", |
| 1188 | command: "model", | |
| 1048 | 1189 | label: "/model", |
| 1049 | 1190 | description: "Switch response model for this thread", |
| 1050 | 1191 | }, |
| 1051 | ]; | |
| 1192 | { | |
| 1193 | id: "slash:plan", | |
| 1194 | type: "slash-command", | |
| 1195 | command: "plan", | |
| 1196 | label: "/plan", | |
| 1197 | description: "Switch this thread into plan mode", | |
| 1198 | }, | |
| 1199 | { | |
| 1200 | id: "slash:default", | |
| 1201 | type: "slash-command", | |
| 1202 | command: "default", | |
| 1203 | label: "/default", | |
| 1204 | description: "Switch this thread back to normal chat mode", | |
| 1205 | }, | |
| 1206 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | |
| 1207 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 1208 | if (!query) { | |
| 1209 | return [...slashCommandItems]; | |
| 1210 | } | |
| 1211 | return slashCommandItems.filter( | |
| 1212 | (item) => item.command.includes(query) || item.label.slice(1).includes(query), | |
| 1213 | ); | |
| 1052 | 1214 | } |
| 1053 | 1215 | |
| 1054 | return searchableModelOptions.filter(({ searchSlug, searchName, searchProvider }) => { | |
| 1055 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 1056 | if (!query) return true; | |
| 1057 | return searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query); | |
| 1058 | }).map(({ provider, providerLabel, slug, name }) => ({ | |
| 1059 | id: `model:${provider}:${slug}`, | |
| 1060 | type: "model", | |
| 1061 | provider, | |
| 1062 | model: slug, | |
| 1063 | label: name, | |
| 1064 | description: `${providerLabel} · ${slug}`, | |
| 1065 | })); | |
| 1216 | return searchableModelOptions | |
| 1217 | .filter(({ searchSlug, searchName, searchProvider }) => { | |
| 1218 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 1219 | if (!query) return true; | |
| 1220 | return ( | |
| 1221 | searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) | |
| 1222 | ); | |
| 1223 | }) | |
| 1224 | .map(({ provider, providerLabel, slug, name }) => ({ | |
| 1225 | id: `model:${provider}:${slug}`, | |
| 1226 | type: "model", | |
| 1227 | provider, | |
| 1228 | model: slug, | |
| 1229 | label: name, | |
| 1230 | description: `${providerLabel} · ${slug}`, | |
| 1231 | })); | |
| 1066 | 1232 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); |
| 1067 | 1233 | const composerMenuOpen = Boolean(composerTrigger); |
| 1068 | 1234 | const activeComposerMenuItem = useMemo( |
| ... | ... | @@ -1444,6 +1610,41 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1444 | 1610 | }); |
| 1445 | 1611 | }; |
| 1446 | 1612 | |
| 1613 | const handleInteractionModeChange = useCallback( | |
| 1614 | async (mode: "default" | "plan") => { | |
| 1615 | if (mode === interactionMode) return; | |
| 1616 | if (isLocalDraftThread) { | |
| 1617 | setDraftThreadContext(threadId, { interactionMode: mode }); | |
| 1618 | scheduleComposerFocus(); | |
| 1619 | return; | |
| 1620 | } | |
| 1621 | const api = readNativeApi(); | |
| 1622 | if (!api || !isServerThread) return; | |
| 1623 | scheduleComposerFocus(); | |
| 1624 | startInteractionModeTransition(() => { | |
| 1625 | setOptimisticInteractionMode(mode); | |
| 1626 | void api.orchestration | |
| 1627 | .dispatchCommand({ | |
| 1628 | type: "thread.interaction-mode.set", | |
| 1629 | commandId: newCommandId(), | |
| 1630 | threadId, | |
| 1631 | interactionMode: mode, | |
| 1632 | createdAt: new Date().toISOString(), | |
| 1633 | }) | |
| 1634 | .catch(() => undefined); | |
| 1635 | }); | |
| 1636 | }, | |
| 1637 | [ | |
| 1638 | interactionMode, | |
| 1639 | isLocalDraftThread, | |
| 1640 | isServerThread, | |
| 1641 | scheduleComposerFocus, | |
| 1642 | setDraftThreadContext, | |
| 1643 | setOptimisticInteractionMode, | |
| 1644 | threadId, | |
| 1645 | ], | |
| 1646 | ); | |
| 1647 | ||
| 1447 | 1648 | useEffect(() => { |
| 1448 | 1649 | try { |
| 1449 | 1650 | if (Object.keys(lastInvokedScriptByProjectId).length === 0) { |
| ... | ... | @@ -2070,7 +2271,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2070 | 2271 | e?.preventDefault(); |
| 2071 | 2272 | const api = readNativeApi(); |
| 2072 | 2273 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; |
| 2274 | if (activePendingProgress) { | |
| 2275 | onAdvanceActivePendingUserInput(); | |
| 2276 | return; | |
| 2277 | } | |
| 2073 | 2278 | const trimmed = prompt.trim(); |
| 2279 | if (showPlanFollowUpPrompt && activeProposedPlan) { | |
| 2280 | const followUpText = | |
| 2281 | trimmed.length > 0 | |
| 2282 | ? trimmed | |
| 2283 | : buildPlanImplementationPrompt(activeProposedPlan.plan.planMarkdown); | |
| 2284 | const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; | |
| 2285 | promptRef.current = ""; | |
| 2286 | clearComposerDraftContent(activeThread.id); | |
| 2287 | setComposerHighlightedItemId(null); | |
| 2288 | setComposerCursor(0); | |
| 2289 | setComposerTrigger(null); | |
| 2290 | await onSubmitPlanFollowUp({ | |
| 2291 | text: followUpText, | |
| 2292 | interactionMode: nextInteractionMode, | |
| 2293 | }); | |
| 2294 | return; | |
| 2295 | } | |
| 2296 | const standaloneSlashCommand = | |
| 2297 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; | |
| 2298 | if (standaloneSlashCommand) { | |
| 2299 | await handleInteractionModeChange(standaloneSlashCommand); | |
| 2300 | promptRef.current = ""; | |
| 2301 | clearComposerDraftContent(activeThread.id); | |
| 2302 | setComposerHighlightedItemId(null); | |
| 2303 | setComposerCursor(0); | |
| 2304 | setComposerTrigger(null); | |
| 2305 | return; | |
| 2306 | } | |
| 2074 | 2307 | if (!trimmed && composerImages.length === 0) return; |
| 2075 | 2308 | if (!activeProject) return; |
| 2076 | 2309 | const threadIdForSend = activeThread.id; |
| ... | ... | @@ -2183,7 +2416,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2183 | 2416 | } |
| 2184 | 2417 | } |
| 2185 | 2418 | const title = truncateTitle(titleSeed); |
| 2186 | let threadCreateModel: ModelSlug = selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL; | |
| 2419 | let threadCreateModel: ModelSlug = | |
| 2420 | selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL; | |
| 2187 | 2421 | |
| 2188 | 2422 | if (isLocalDraftThread) { |
| 2189 | 2423 | await api.orchestration.dispatchCommand({ |
| ... | ... | @@ -2194,6 +2428,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2194 | 2428 | title, |
| 2195 | 2429 | model: threadCreateModel, |
| 2196 | 2430 | runtimeMode, |
| 2431 | interactionMode, | |
| 2197 | 2432 | branch: nextThreadBranch, |
| 2198 | 2433 | worktreePath: nextThreadWorktreePath, |
| 2199 | 2434 | createdAt: activeThread.createdAt, |
| ... | ... | @@ -2254,6 +2489,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2254 | 2489 | ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), |
| 2255 | 2490 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", |
| 2256 | 2491 | runtimeMode, |
| 2492 | interactionMode, | |
| 2257 | 2493 | createdAt: messageCreatedAt, |
| 2258 | 2494 | }); |
| 2259 | 2495 | turnStartSucceeded = true; |
| ... | ... | @@ -2337,6 +2573,345 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2337 | 2573 | [activeThreadId, setStoreThreadError], |
| 2338 | 2574 | ); |
| 2339 | 2575 | |
| 2576 | const onRespondToUserInput = useCallback( | |
| 2577 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { | |
| 2578 | const api = readNativeApi(); | |
| 2579 | if (!api || !activeThreadId) return; | |
| 2580 | ||
| 2581 | setRespondingUserInputRequestIds((existing) => | |
| 2582 | existing.includes(requestId) ? existing : [...existing, requestId], | |
| 2583 | ); | |
| 2584 | await api.orchestration | |
| 2585 | .dispatchCommand({ | |
| 2586 | type: "thread.user-input.respond", | |
| 2587 | commandId: newCommandId(), | |
| 2588 | threadId: activeThreadId, | |
| 2589 | requestId, | |
| 2590 | answers, | |
| 2591 | createdAt: new Date().toISOString(), | |
| 2592 | }) | |
| 2593 | .catch((err: unknown) => { | |
| 2594 | setStoreThreadError( | |
| 2595 | activeThreadId, | |
| 2596 | err instanceof Error ? err.message : "Failed to submit user input.", | |
| 2597 | ); | |
| 2598 | }); | |
| 2599 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); | |
| 2600 | }, | |
| 2601 | [activeThreadId, setStoreThreadError], | |
| 2602 | ); | |
| 2603 | ||
| 2604 | const setActivePendingUserInputQuestionIndex = useCallback( | |
| 2605 | (nextQuestionIndex: number) => { | |
| 2606 | if (!activePendingUserInput) { | |
| 2607 | return; | |
| 2608 | } | |
| 2609 | setPendingUserInputQuestionIndexByRequestId((existing) => ({ | |
| 2610 | ...existing, | |
| 2611 | [activePendingUserInput.requestId]: nextQuestionIndex, | |
| 2612 | })); | |
| 2613 | }, | |
| 2614 | [activePendingUserInput], | |
| 2615 | ); | |
| 2616 | ||
| 2617 | const onSelectActivePendingUserInputOption = useCallback( | |
| 2618 | (questionId: string, optionLabel: string) => { | |
| 2619 | if (!activePendingUserInput) { | |
| 2620 | return; | |
| 2621 | } | |
| 2622 | setPendingUserInputAnswersByRequestId((existing) => ({ | |
| 2623 | ...existing, | |
| 2624 | [activePendingUserInput.requestId]: { | |
| 2625 | ...existing[activePendingUserInput.requestId], | |
| 2626 | [questionId]: { | |
| 2627 | selectedOptionLabel: optionLabel, | |
| 2628 | customAnswer: "", | |
| 2629 | }, | |
| 2630 | }, | |
| 2631 | })); | |
| 2632 | promptRef.current = ""; | |
| 2633 | setComposerCursor(0); | |
| 2634 | setComposerTrigger(null); | |
| 2635 | }, | |
| 2636 | [activePendingUserInput], | |
| 2637 | ); | |
| 2638 | ||
| 2639 | const onChangeActivePendingUserInputCustomAnswer = useCallback( | |
| 2640 | (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { | |
| 2641 | if (!activePendingUserInput) { | |
| 2642 | return; | |
| 2643 | } | |
| 2644 | promptRef.current = value; | |
| 2645 | setPendingUserInputAnswersByRequestId((existing) => ({ | |
| 2646 | ...existing, | |
| 2647 | [activePendingUserInput.requestId]: { | |
| 2648 | ...existing[activePendingUserInput.requestId], | |
| 2649 | [questionId]: setPendingUserInputCustomAnswer( | |
| 2650 | existing[activePendingUserInput.requestId]?.[questionId], | |
| 2651 | value, | |
| 2652 | ), | |
| 2653 | }, | |
| 2654 | })); | |
| 2655 | setComposerCursor(nextCursor); | |
| 2656 | setComposerTrigger( | |
| 2657 | cursorAdjacentToMention | |
| 2658 | ? null | |
| 2659 | : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), | |
| 2660 | ); | |
| 2661 | }, | |
| 2662 | [activePendingUserInput], | |
| 2663 | ); | |
| 2664 | ||
| 2665 | const onAdvanceActivePendingUserInput = useCallback(() => { | |
| 2666 | if (!activePendingUserInput || !activePendingProgress) { | |
| 2667 | return; | |
| 2668 | } | |
| 2669 | if (activePendingProgress.isLastQuestion) { | |
| 2670 | if (activePendingResolvedAnswers) { | |
| 2671 | void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); | |
| 2672 | } | |
| 2673 | return; | |
| 2674 | } | |
| 2675 | setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); | |
| 2676 | }, [ | |
| 2677 | activePendingProgress, | |
| 2678 | activePendingResolvedAnswers, | |
| 2679 | activePendingUserInput, | |
| 2680 | onRespondToUserInput, | |
| 2681 | setActivePendingUserInputQuestionIndex, | |
| 2682 | ]); | |
| 2683 | ||
| 2684 | const onPreviousActivePendingUserInputQuestion = useCallback(() => { | |
| 2685 | if (!activePendingProgress) { | |
| 2686 | return; | |
| 2687 | } | |
| 2688 | setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); | |
| 2689 | }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); | |
| 2690 | ||
| 2691 | const onSubmitPlanFollowUp = useCallback( | |
| 2692 | async ({ | |
| 2693 | text, | |
| 2694 | interactionMode: nextInteractionMode, | |
| 2695 | }: { | |
| 2696 | text: string; | |
| 2697 | interactionMode: "default" | "plan"; | |
| 2698 | }) => { | |
| 2699 | const api = readNativeApi(); | |
| 2700 | if ( | |
| 2701 | !api || | |
| 2702 | !activeThread || | |
| 2703 | !isServerThread || | |
| 2704 | isSendBusy || | |
| 2705 | isConnecting || | |
| 2706 | sendInFlightRef.current | |
| 2707 | ) { | |
| 2708 | return; | |
| 2709 | } | |
| 2710 | ||
| 2711 | const trimmed = text.trim(); | |
| 2712 | if (!trimmed) { | |
| 2713 | return; | |
| 2714 | } | |
| 2715 | ||
| 2716 | const threadIdForSend = activeThread.id; | |
| 2717 | const messageIdForSend = newMessageId(); | |
| 2718 | const messageCreatedAt = new Date().toISOString(); | |
| 2719 | ||
| 2720 | sendInFlightRef.current = true; | |
| 2721 | setSendPhase("sending-turn"); | |
| 2722 | setThreadError(threadIdForSend, null); | |
| 2723 | setOptimisticUserMessages((existing) => [ | |
| 2724 | ...existing, | |
| 2725 | { | |
| 2726 | id: messageIdForSend, | |
| 2727 | role: "user", | |
| 2728 | text: trimmed, | |
| 2729 | createdAt: messageCreatedAt, | |
| 2730 | streaming: false, | |
| 2731 | }, | |
| 2732 | ]); | |
| 2733 | shouldAutoScrollRef.current = true; | |
| 2734 | forceStickToBottom(); | |
| 2735 | ||
| 2736 | try { | |
| 2737 | if (nextInteractionMode !== interactionMode) { | |
| 2738 | setOptimisticInteractionMode(nextInteractionMode); | |
| 2739 | await api.orchestration.dispatchCommand({ | |
| 2740 | type: "thread.interaction-mode.set", | |
| 2741 | commandId: newCommandId(), | |
| 2742 | threadId: threadIdForSend, | |
| 2743 | interactionMode: nextInteractionMode, | |
| 2744 | createdAt: messageCreatedAt, | |
| 2745 | }); | |
| 2746 | } | |
| 2747 | ||
| 2748 | await api.orchestration.dispatchCommand({ | |
| 2749 | type: "thread.turn.start", | |
| 2750 | commandId: newCommandId(), | |
| 2751 | threadId: threadIdForSend, | |
| 2752 | message: { | |
| 2753 | messageId: messageIdForSend, | |
| 2754 | role: "user", | |
| 2755 | text: trimmed, | |
| 2756 | attachments: [], | |
| 2757 | }, | |
| 2758 | provider: selectedProvider, | |
| 2759 | model: selectedModel || undefined, | |
| 2760 | ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), | |
| 2761 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| 2762 | runtimeMode, | |
| 2763 | interactionMode: nextInteractionMode, | |
| 2764 | createdAt: messageCreatedAt, | |
| 2765 | }); | |
| 2766 | sendInFlightRef.current = false; | |
| 2767 | setSendPhase("idle"); | |
| 2768 | } catch (err) { | |
| 2769 | setOptimisticUserMessages((existing) => | |
| 2770 | existing.filter((message) => message.id !== messageIdForSend), | |
| 2771 | ); | |
| 2772 | if (nextInteractionMode !== interactionMode) { | |
| 2773 | setOptimisticInteractionMode(interactionMode); | |
| 2774 | } | |
| 2775 | setThreadError( | |
| 2776 | threadIdForSend, | |
| 2777 | err instanceof Error ? err.message : "Failed to send plan follow-up.", | |
| 2778 | ); | |
| 2779 | sendInFlightRef.current = false; | |
| 2780 | setSendPhase("idle"); | |
| 2781 | } | |
| 2782 | }, | |
| 2783 | [ | |
| 2784 | activeThread, | |
| 2785 | forceStickToBottom, | |
| 2786 | interactionMode, | |
| 2787 | isConnecting, | |
| 2788 | isSendBusy, | |
| 2789 | isServerThread, | |
| 2790 | runtimeMode, | |
| 2791 | selectedEffort, | |
| 2792 | selectedModel, | |
| 2793 | selectedProvider, | |
| 2794 | setOptimisticInteractionMode, | |
| 2795 | setThreadError, | |
| 2796 | settings.enableAssistantStreaming, | |
| 2797 | supportsReasoningEffort, | |
| 2798 | ], | |
| 2799 | ); | |
| 2800 | ||
| 2801 | const onImplementPlanInNewThread = useCallback(async () => { | |
| 2802 | const api = readNativeApi(); | |
| 2803 | if ( | |
| 2804 | !api || | |
| 2805 | !activeThread || | |
| 2806 | !activeProject || | |
| 2807 | !activeProposedPlan || | |
| 2808 | !isServerThread || | |
| 2809 | isSendBusy || | |
| 2810 | isConnecting || | |
| 2811 | sendInFlightRef.current | |
| 2812 | ) { | |
| 2813 | return; | |
| 2814 | } | |
| 2815 | ||
| 2816 | const createdAt = new Date().toISOString(); | |
| 2817 | const nextThreadId = newThreadId(); | |
| 2818 | const planMarkdown = activeProposedPlan.plan.planMarkdown; | |
| 2819 | const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); | |
| 2820 | const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); | |
| 2821 | const nextThreadModel: ModelSlug = | |
| 2822 | selectedModel || | |
| 2823 | (activeThread.model as ModelSlug) || | |
| 2824 | (activeProject.model as ModelSlug) || | |
| 2825 | DEFAULT_MODEL; | |
| 2826 | ||
| 2827 | sendInFlightRef.current = true; | |
| 2828 | setSendPhase("sending-turn"); | |
| 2829 | const finish = () => { | |
| 2830 | sendInFlightRef.current = false; | |
| 2831 | setSendPhase("idle"); | |
| 2832 | }; | |
| 2833 | ||
| 2834 | await api.orchestration | |
| 2835 | .dispatchCommand({ | |
| 2836 | type: "thread.create", | |
| 2837 | commandId: newCommandId(), | |
| 2838 | threadId: nextThreadId, | |
| 2839 | projectId: activeProject.id, | |
| 2840 | title: nextThreadTitle, | |
| 2841 | model: nextThreadModel, | |
| 2842 | runtimeMode, | |
| 2843 | interactionMode: "default", | |
| 2844 | branch: activeThread.branch, | |
| 2845 | worktreePath: activeThread.worktreePath, | |
| 2846 | createdAt, | |
| 2847 | }) | |
| 2848 | .then(() => | |
| 2849 | api.orchestration.dispatchCommand({ | |
| 2850 | type: "thread.turn.start", | |
| 2851 | commandId: newCommandId(), | |
| 2852 | threadId: nextThreadId, | |
| 2853 | message: { | |
| 2854 | messageId: newMessageId(), | |
| 2855 | role: "user", | |
| 2856 | text: implementationPrompt, | |
| 2857 | attachments: [], | |
| 2858 | }, | |
| 2859 | provider: selectedProvider, | |
| 2860 | model: selectedModel || undefined, | |
| 2861 | ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), | |
| 2862 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| 2863 | runtimeMode, | |
| 2864 | interactionMode: "default", | |
| 2865 | createdAt, | |
| 2866 | }), | |
| 2867 | ) | |
| 2868 | .then(() => api.orchestration.getSnapshot()) | |
| 2869 | .then((snapshot) => { | |
| 2870 | syncServerReadModel(snapshot); | |
| 2871 | return navigate({ | |
| 2872 | to: "/$threadId", | |
| 2873 | params: { threadId: nextThreadId }, | |
| 2874 | }); | |
| 2875 | }) | |
| 2876 | .catch(async (err) => { | |
| 2877 | await api.orchestration | |
| 2878 | .dispatchCommand({ | |
| 2879 | type: "thread.delete", | |
| 2880 | commandId: newCommandId(), | |
| 2881 | threadId: nextThreadId, | |
| 2882 | }) | |
| 2883 | .catch(() => undefined); | |
| 2884 | await api.orchestration | |
| 2885 | .getSnapshot() | |
| 2886 | .then((snapshot) => { | |
| 2887 | syncServerReadModel(snapshot); | |
| 2888 | }) | |
| 2889 | .catch(() => undefined); | |
| 2890 | toastManager.add({ | |
| 2891 | type: "error", | |
| 2892 | title: "Could not start implementation thread", | |
| 2893 | description: | |
| 2894 | err instanceof Error ? err.message : "An error occurred while creating the new thread.", | |
| 2895 | }); | |
| 2896 | }) | |
| 2897 | .then(finish, finish); | |
| 2898 | }, [ | |
| 2899 | activeProject, | |
| 2900 | activeProposedPlan, | |
| 2901 | activeThread, | |
| 2902 | isConnecting, | |
| 2903 | isSendBusy, | |
| 2904 | isServerThread, | |
| 2905 | navigate, | |
| 2906 | runtimeMode, | |
| 2907 | selectedEffort, | |
| 2908 | selectedModel, | |
| 2909 | selectedProvider, | |
| 2910 | settings.enableAssistantStreaming, | |
| 2911 | supportsReasoningEffort, | |
| 2912 | syncServerReadModel, | |
| 2913 | ]); | |
| 2914 | ||
| 2340 | 2915 | const onProviderModelSelect = useCallback( |
| 2341 | 2916 | (provider: ProviderKind, model: ModelSlug) => { |
| 2342 | 2917 | if (!activeThread) return; |
| ... | ... | @@ -2452,7 +3027,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2452 | 3027 | } |
| 2453 | 3028 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 2454 | 3029 | promptRef.current = next.text; |
| 2455 | setPrompt(next.text); | |
| 3030 | const activePendingQuestion = activePendingProgress?.activeQuestion; | |
| 3031 | if (activePendingQuestion && activePendingUserInput) { | |
| 3032 | setPendingUserInputAnswersByRequestId((existing) => ({ | |
| 3033 | ...existing, | |
| 3034 | [activePendingUserInput.requestId]: { | |
| 3035 | ...existing[activePendingUserInput.requestId], | |
| 3036 | [activePendingQuestion.id]: setPendingUserInputCustomAnswer( | |
| 3037 | existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], | |
| 3038 | next.text, | |
| 3039 | ), | |
| 3040 | }, | |
| 3041 | })); | |
| 3042 | } else { | |
| 3043 | setPrompt(next.text); | |
| 3044 | } | |
| 2456 | 3045 | setComposerCursor(next.cursor); |
| 2457 | 3046 | setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); |
| 2458 | 3047 | window.requestAnimationFrame(() => { |
| ... | ... | @@ -2460,7 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2460 | 3049 | }); |
| 2461 | 3050 | return true; |
| 2462 | 3051 | }, |
| 2463 | [setPrompt], | |
| 3052 | [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], | |
| 2464 | 3053 | ); |
| 2465 | 3054 | |
| 2466 | 3055 | const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { |
| ... | ... | @@ -2506,7 +3095,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2506 | 3095 | return; |
| 2507 | 3096 | } |
| 2508 | 3097 | if (item.type === "slash-command") { |
| 2509 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { | |
| 3098 | if (item.command === "model") { | |
| 3099 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { | |
| 3100 | expectedText: expectedToken, | |
| 3101 | }); | |
| 3102 | if (applied) { | |
| 3103 | setComposerHighlightedItemId(null); | |
| 3104 | } | |
| 3105 | return; | |
| 3106 | } | |
| 3107 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | |
| 3108 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | |
| 2510 | 3109 | expectedText: expectedToken, |
| 2511 | 3110 | }); |
| 2512 | 3111 | if (applied) { |
| ... | ... | @@ -2522,7 +3121,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2522 | 3121 | setComposerHighlightedItemId(null); |
| 2523 | 3122 | } |
| 2524 | 3123 | }, |
| 2525 | [applyPromptReplacement, onProviderModelSelect, resolveActiveComposerTrigger], | |
| 3124 | [ | |
| 3125 | applyPromptReplacement, | |
| 3126 | handleInteractionModeChange, | |
| 3127 | onProviderModelSelect, | |
| 3128 | resolveActiveComposerTrigger, | |
| 3129 | ], | |
| 2526 | 3130 | ); |
| 2527 | 3131 | const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { |
| 2528 | 3132 | setComposerHighlightedItemId(itemId); |
| ... | ... | @@ -2553,6 +3157,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2553 | 3157 | |
| 2554 | 3158 | const onPromptChange = useCallback( |
| 2555 | 3159 | (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { |
| 3160 | if (activePendingProgress?.activeQuestion && activePendingUserInput) { | |
| 3161 | onChangeActivePendingUserInputCustomAnswer( | |
| 3162 | activePendingProgress.activeQuestion.id, | |
| 3163 | nextPrompt, | |
| 3164 | nextCursor, | |
| 3165 | cursorAdjacentToMention, | |
| 3166 | ); | |
| 3167 | return; | |
| 3168 | } | |
| 2556 | 3169 | promptRef.current = nextPrompt; |
| 2557 | 3170 | setPrompt(nextPrompt); |
| 2558 | 3171 | setComposerCursor(nextCursor); |
| ... | ... | @@ -2565,7 +3178,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2565 | 3178 | ), |
| 2566 | 3179 | ); |
| 2567 | 3180 | }, |
| 2568 | [setPrompt], | |
| 3181 | [ | |
| 3182 | activePendingProgress?.activeQuestion, | |
| 3183 | activePendingUserInput, | |
| 3184 | onChangeActivePendingUserInputCustomAnswer, | |
| 3185 | setPrompt, | |
| 3186 | ], | |
| 2569 | 3187 | ); |
| 2570 | 3188 | |
| 2571 | 3189 | const onComposerCommandKey = ( |
| ... | ... | @@ -2698,6 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2698 | 3316 | respondingRequestIds={respondingRequestIds} |
| 2699 | 3317 | onRespondToApproval={onRespondToApproval} |
| 2700 | 3318 | /> |
| 3319 | <PlanModePanel activePlan={activePlan} /> | |
| 2701 | 3320 | |
| 2702 | 3321 | {/* Messages */} |
| 2703 | 3322 | <div |
| ... | ... | @@ -2734,6 +3353,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2734 | 3353 | onImageExpand={onExpandTimelineImage} |
| 2735 | 3354 | markdownCwd={gitCwd ?? undefined} |
| 2736 | 3355 | resolvedTheme={resolvedTheme} |
| 3356 | workspaceRoot={activeProject?.cwd ?? undefined} | |
| 2737 | 3357 | /> |
| 2738 | 3358 | </div> |
| 2739 | 3359 | |
| ... | ... | @@ -2754,8 +3374,32 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2754 | 3374 | onDragLeave={onComposerDragLeave} |
| 2755 | 3375 | onDrop={onComposerDrop} |
| 2756 | 3376 | > |
| 3377 | {pendingUserInputs.length > 0 ? ( | |
| 3378 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 3379 | <ComposerPendingUserInputPanel | |
| 3380 | pendingUserInputs={pendingUserInputs} | |
| 3381 | respondingRequestIds={respondingUserInputRequestIds} | |
| 3382 | answers={activePendingDraftAnswers} | |
| 3383 | questionIndex={activePendingQuestionIndex} | |
| 3384 | onSelectOption={onSelectActivePendingUserInputOption} | |
| 3385 | /> | |
| 3386 | </div> | |
| 3387 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( | |
| 3388 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | |
| 3389 | <ComposerPlanFollowUpBanner | |
| 3390 | key={activeProposedPlan.message.id} | |
| 3391 | planTitle={proposedPlanTitle(activeProposedPlan.plan.planMarkdown) ?? null} | |
| 3392 | /> | |
| 3393 | </div> | |
| 3394 | ) : null} | |
| 3395 | ||
| 2757 | 3396 | {/* Textarea area */} |
| 2758 | <div className="relative px-3 pt-3.5 pb-2 sm:px-4 sm:pt-4"> | |
| 3397 | <div | |
| 3398 | className={cn( | |
| 3399 | "relative px-3 pb-2 sm:px-4", | |
| 3400 | hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4", | |
| 3401 | )} | |
| 3402 | > | |
| 2759 | 3403 | {composerMenuOpen && ( |
| 2760 | 3404 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 2761 | 3405 | <ComposerCommandMenu |
| ... | ... | @@ -2770,7 +3414,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2770 | 3414 | </div> |
| 2771 | 3415 | )} |
| 2772 | 3416 | |
| 2773 | {composerImages.length > 0 && ( | |
| 3417 | {pendingUserInputs.length === 0 && composerImages.length > 0 && ( | |
| 2774 | 3418 | <div className="mb-3 flex flex-wrap gap-2"> |
| 2775 | 3419 | {composerImages.map((image) => ( |
| 2776 | 3420 | <div |
| ... | ... | @@ -2836,15 +3480,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2836 | 3480 | )} |
| 2837 | 3481 | <ComposerPromptEditor |
| 2838 | 3482 | ref={composerEditorRef} |
| 2839 | value={prompt} | |
| 3483 | value={activePendingProgress ? activePendingProgress.customAnswer : prompt} | |
| 2840 | 3484 | cursor={composerCursor} |
| 2841 | 3485 | onChange={onPromptChange} |
| 2842 | 3486 | onCommandKeyDown={onComposerCommandKey} |
| 2843 | 3487 | onPaste={onComposerPaste} |
| 2844 | 3488 | placeholder={ |
| 2845 | phase === "disconnected" | |
| 2846 | ? "Ask for follow-up changes or attach images" | |
| 2847 | : "Ask anything, @tag files/folders, or use /model" | |
| 3489 | activePendingProgress | |
| 3490 | ? "Type your own answer, or leave this blank to use the selected option" | |
| 3491 | : showPlanFollowUpPrompt && activeProposedPlan | |
| 3492 | ? "Add feedback to refine the plan, or leave this blank to implement it" | |
| 3493 | : phase === "disconnected" | |
| 3494 | ? "Ask for follow-up changes or attach images" | |
| 3495 | : "Ask anything, @tag files/folders, or use /model" | |
| 2848 | 3496 | } |
| 2849 | 3497 | disabled={isConnecting} |
| 2850 | 3498 | /> |
| ... | ... | @@ -2890,39 +3538,44 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2890 | 3538 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 2891 | 3539 | )} |
| 2892 | 3540 | |
| 2893 | {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && ( | |
| 2894 | <> | |
| 2895 | {cursorModelSelectionLockedReason ? ( | |
| 2896 | <Tooltip> | |
| 2897 | <TooltipTrigger | |
| 2898 | render={ | |
| 2899 | <span className="inline-flex"> | |
| 2900 | <CursorTraitsPicker | |
| 2901 | selection={selectedCursorModel} | |
| 2902 | capabilities={selectedCursorModelCapabilities} | |
| 2903 | disabled | |
| 2904 | onReasoningChange={onCursorReasoningSelect} | |
| 2905 | onFastModeChange={onCursorFastModeChange} | |
| 2906 | onThinkingModeChange={onCursorThinkingModeChange} | |
| 2907 | /> | |
| 2908 | </span> | |
| 2909 | } | |
| 3541 | {selectedCursorModel && | |
| 3542 | selectedCursorModelCapabilities && | |
| 3543 | hasSelectedCursorTraits && ( | |
| 3544 | <> | |
| 3545 | {cursorModelSelectionLockedReason ? ( | |
| 3546 | <Tooltip> | |
| 3547 | <TooltipTrigger | |
| 3548 | render={ | |
| 3549 | <span className="inline-flex"> | |
| 3550 | <CursorTraitsPicker | |
| 3551 | selection={selectedCursorModel} | |
| 3552 | capabilities={selectedCursorModelCapabilities} | |
| 3553 | disabled | |
| 3554 | onReasoningChange={onCursorReasoningSelect} | |
| 3555 | onFastModeChange={onCursorFastModeChange} | |
| 3556 | onThinkingModeChange={onCursorThinkingModeChange} | |
| 3557 | /> | |
| 3558 | </span> | |
| 3559 | } | |
| 3560 | /> | |
| 3561 | <TooltipPopup | |
| 3562 | side="top" | |
| 3563 | className="max-w-72 whitespace-normal leading-tight" | |
| 3564 | > | |
| 3565 | {cursorModelSelectionLockedReason} | |
| 3566 | </TooltipPopup> | |
| 3567 | </Tooltip> | |
| 3568 | ) : ( | |
| 3569 | <CursorTraitsPicker | |
| 3570 | selection={selectedCursorModel} | |
| 3571 | capabilities={selectedCursorModelCapabilities} | |
| 3572 | onReasoningChange={onCursorReasoningSelect} | |
| 3573 | onFastModeChange={onCursorFastModeChange} | |
| 3574 | onThinkingModeChange={onCursorThinkingModeChange} | |
| 2910 | 3575 | /> |
| 2911 | <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-tight"> | |
| 2912 | {cursorModelSelectionLockedReason} | |
| 2913 | </TooltipPopup> | |
| 2914 | </Tooltip> | |
| 2915 | ) : ( | |
| 2916 | <CursorTraitsPicker | |
| 2917 | selection={selectedCursorModel} | |
| 2918 | capabilities={selectedCursorModelCapabilities} | |
| 2919 | onReasoningChange={onCursorReasoningSelect} | |
| 2920 | onFastModeChange={onCursorFastModeChange} | |
| 2921 | onThinkingModeChange={onCursorThinkingModeChange} | |
| 2922 | /> | |
| 2923 | )} | |
| 2924 | </> | |
| 2925 | )} | |
| 3576 | )} | |
| 3577 | </> | |
| 3578 | )} | |
| 2926 | 3579 | </> |
| 2927 | 3580 | ) : supportsReasoningEffort ? ( |
| 2928 | 3581 | <> |
| ... | ... | @@ -2943,6 +3596,33 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2943 | 3596 | {/* Divider */} |
| 2944 | 3597 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 2945 | 3598 | |
| 3599 | {/* Interaction mode toggle */} | |
| 3600 | <Button | |
| 3601 | variant="ghost" | |
| 3602 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 3603 | size="sm" | |
| 3604 | type="button" | |
| 3605 | disabled={isInteractionModePending} | |
| 3606 | onClick={() => | |
| 3607 | void handleInteractionModeChange( | |
| 3608 | optimisticInteractionMode === "plan" ? "default" : "plan", | |
| 3609 | ) | |
| 3610 | } | |
| 3611 | title={ | |
| 3612 | optimisticInteractionMode === "plan" | |
| 3613 | ? "Plan mode — click to return to normal chat mode" | |
| 3614 | : "Default mode — click to enter plan mode" | |
| 3615 | } | |
| 3616 | > | |
| 3617 | <BotIcon /> | |
| 3618 | <span className="sr-only sm:not-sr-only"> | |
| 3619 | {optimisticInteractionMode === "plan" ? "Plan" : "Chat"} | |
| 3620 | </span> | |
| 3621 | </Button> | |
| 3622 | ||
| 3623 | {/* Divider */} | |
| 3624 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 3625 | ||
| 2946 | 3626 | {/* Runtime mode toggle */} |
| 2947 | 3627 | <Button |
| 2948 | 3628 | variant="ghost" |
| ... | ... | @@ -2952,9 +3632,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2952 | 3632 | disabled={isRuntimeModePending} |
| 2953 | 3633 | onClick={() => |
| 2954 | 3634 | void handleRuntimeModeChange( |
| 2955 | optimisticRuntimeMode === "full-access" | |
| 2956 | ? "approval-required" | |
| 2957 | : "full-access", | |
| 3635 | optimisticRuntimeMode === "full-access" ? "approval-required" : "full-access", | |
| 2958 | 3636 | ) |
| 2959 | 3637 | } |
| 2960 | 3638 | title={ |
| ... | ... | @@ -2975,10 +3653,41 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2975 | 3653 | {isPreparingWorktree ? ( |
| 2976 | 3654 | <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> |
| 2977 | 3655 | ) : null} |
| 2978 | {phase === "running" ? ( | |
| 3656 | {activePendingProgress ? ( | |
| 3657 | <div className="flex items-center gap-2"> | |
| 3658 | {activePendingProgress.questionIndex > 0 ? ( | |
| 3659 | <Button | |
| 3660 | size="sm" | |
| 3661 | variant="outline" | |
| 3662 | className="rounded-full" | |
| 3663 | onClick={onPreviousActivePendingUserInputQuestion} | |
| 3664 | disabled={activePendingIsResponding} | |
| 3665 | > | |
| 3666 | Previous | |
| 3667 | </Button> | |
| 3668 | ) : null} | |
| 3669 | <Button | |
| 3670 | type="submit" | |
| 3671 | size="sm" | |
| 3672 | className="rounded-full px-4" | |
| 3673 | disabled={ | |
| 3674 | activePendingIsResponding || | |
| 3675 | (activePendingProgress.isLastQuestion | |
| 3676 | ? !activePendingResolvedAnswers | |
| 3677 | : !activePendingProgress.canAdvance) | |
| 3678 | } | |
| 3679 | > | |
| 3680 | {activePendingIsResponding | |
| 3681 | ? "Submitting..." | |
| 3682 | : activePendingProgress.isLastQuestion | |
| 3683 | ? "Submit answers" | |
| 3684 | : "Next question"} | |
| 3685 | </Button> | |
| 3686 | </div> | |
| 3687 | ) : phase === "running" ? ( | |
| 2979 | 3688 | <button |
| 2980 | 3689 | type="button" |
| 2981 | className="flex h-9 w-9 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8" | |
| 3690 | className="flex size-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8" | |
| 2982 | 3691 | onClick={() => void onInterrupt()} |
| 2983 | 3692 | aria-label="Stop generation" |
| 2984 | 3693 | > |
| ... | ... | @@ -2992,61 +3701,110 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2992 | 3701 | <rect x="2" y="2" width="8" height="8" rx="1.5" /> |
| 2993 | 3702 | </svg> |
| 2994 | 3703 | </button> |
| 2995 | ) : ( | |
| 2996 | <button | |
| 2997 | type="submit" | |
| 2998 | className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" | |
| 2999 | disabled={ | |
| 3000 | isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0) | |
| 3001 | } | |
| 3002 | aria-label={ | |
| 3003 | isConnecting | |
| 3004 | ? "Connecting" | |
| 3005 | : isPreparingWorktree | |
| 3006 | ? "Preparing worktree" | |
| 3007 | : isSendBusy | |
| 3008 | ? "Sending" | |
| 3009 | : "Send message" | |
| 3010 | } | |
| 3011 | > | |
| 3012 | {isConnecting || isSendBusy ? ( | |
| 3013 | <svg | |
| 3014 | width="14" | |
| 3015 | height="14" | |
| 3016 | viewBox="0 0 14 14" | |
| 3017 | fill="none" | |
| 3018 | className="animate-spin" | |
| 3019 | aria-hidden="true" | |
| 3704 | ) : pendingUserInputs.length === 0 ? ( | |
| 3705 | showPlanFollowUpPrompt ? ( | |
| 3706 | prompt.trim().length > 0 ? ( | |
| 3707 | <Button | |
| 3708 | type="submit" | |
| 3709 | size="sm" | |
| 3710 | className="h-9 rounded-full px-4 sm:h-8" | |
| 3711 | disabled={isSendBusy || isConnecting} | |
| 3020 | 3712 | > |
| 3021 | <circle | |
| 3022 | cx="7" | |
| 3023 | cy="7" | |
| 3024 | r="5.5" | |
| 3025 | stroke="currentColor" | |
| 3026 | strokeWidth="1.5" | |
| 3027 | strokeLinecap="round" | |
| 3028 | strokeDasharray="20 12" | |
| 3029 | /> | |
| 3030 | </svg> | |
| 3713 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} | |
| 3714 | </Button> | |
| 3031 | 3715 | ) : ( |
| 3032 | <svg | |
| 3033 | width="14" | |
| 3034 | height="14" | |
| 3035 | viewBox="0 0 14 14" | |
| 3036 | fill="none" | |
| 3037 | aria-hidden="true" | |
| 3038 | > | |
| 3039 | <path | |
| 3040 | d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | |
| 3041 | stroke="currentColor" | |
| 3042 | strokeWidth="1.8" | |
| 3043 | strokeLinecap="round" | |
| 3044 | strokeLinejoin="round" | |
| 3045 | /> | |
| 3046 | </svg> | |
| 3047 | )} | |
| 3048 | </button> | |
| 3049 | )} | |
| 3716 | <div className="flex items-center"> | |
| 3717 | <Button | |
| 3718 | type="submit" | |
| 3719 | size="sm" | |
| 3720 | className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" | |
| 3721 | disabled={isSendBusy || isConnecting} | |
| 3722 | > | |
| 3723 | {isConnecting || isSendBusy ? "Sending..." : "Implement"} | |
| 3724 | </Button> | |
| 3725 | <Menu> | |
| 3726 | <MenuTrigger | |
| 3727 | render={ | |
| 3728 | <Button | |
| 3729 | size="sm" | |
| 3730 | variant="default" | |
| 3731 | className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" | |
| 3732 | aria-label="Implementation actions" | |
| 3733 | disabled={isSendBusy || isConnecting} | |
| 3734 | /> | |
| 3735 | } | |
| 3736 | > | |
| 3737 | <ChevronDownIcon className="size-3.5" /> | |
| 3738 | </MenuTrigger> | |
| 3739 | <MenuPopup align="end" side="top"> | |
| 3740 | <MenuItem | |
| 3741 | disabled={isSendBusy || isConnecting} | |
| 3742 | onClick={() => void onImplementPlanInNewThread()} | |
| 3743 | > | |
| 3744 | Implement in new thread | |
| 3745 | </MenuItem> | |
| 3746 | </MenuPopup> | |
| 3747 | </Menu> | |
| 3748 | </div> | |
| 3749 | ) | |
| 3750 | ) : ( | |
| 3751 | <button | |
| 3752 | type="submit" | |
| 3753 | className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" | |
| 3754 | disabled={ | |
| 3755 | isSendBusy || | |
| 3756 | isConnecting || | |
| 3757 | (!prompt.trim() && composerImages.length === 0) | |
| 3758 | } | |
| 3759 | aria-label={ | |
| 3760 | isConnecting | |
| 3761 | ? "Connecting" | |
| 3762 | : isPreparingWorktree | |
| 3763 | ? "Preparing worktree" | |
| 3764 | : isSendBusy | |
| 3765 | ? "Sending" | |
| 3766 | : "Send message" | |
| 3767 | } | |
| 3768 | > | |
| 3769 | {isConnecting || isSendBusy ? ( | |
| 3770 | <svg | |
| 3771 | width="14" | |
| 3772 | height="14" | |
| 3773 | viewBox="0 0 14 14" | |
| 3774 | fill="none" | |
| 3775 | className="animate-spin" | |
| 3776 | aria-hidden="true" | |
| 3777 | > | |
| 3778 | <circle | |
| 3779 | cx="7" | |
| 3780 | cy="7" | |
| 3781 | r="5.5" | |
| 3782 | stroke="currentColor" | |
| 3783 | strokeWidth="1.5" | |
| 3784 | strokeLinecap="round" | |
| 3785 | strokeDasharray="20 12" | |
| 3786 | /> | |
| 3787 | </svg> | |
| 3788 | ) : ( | |
| 3789 | <svg | |
| 3790 | width="14" | |
| 3791 | height="14" | |
| 3792 | viewBox="0 0 14 14" | |
| 3793 | fill="none" | |
| 3794 | aria-hidden="true" | |
| 3795 | > | |
| 3796 | <path | |
| 3797 | d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | |
| 3798 | stroke="currentColor" | |
| 3799 | strokeWidth="1.8" | |
| 3800 | strokeLinecap="round" | |
| 3801 | strokeLinejoin="round" | |
| 3802 | /> | |
| 3803 | </svg> | |
| 3804 | )} | |
| 3805 | </button> | |
| 3806 | ) | |
| 3807 | ) : null} | |
| 3050 | 3808 | </div> |
| 3051 | 3809 | </div> |
| 3052 | 3810 | </div> |
| ... | ... | @@ -3326,7 +4084,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 3326 | 4084 | ? "Command approval requested" |
| 3327 | 4085 | : approval.requestKind === "file-read" |
| 3328 | 4086 | ? "File-read approval requested" |
| 3329 | : "File-change approval requested"} | |
| 4087 | : "File-change approval requested"} | |
| 3330 | 4088 | </AlertTitle> |
| 3331 | 4089 | <AlertDescription |
| 3332 | 4090 | className="truncate block font-mono text-[11px]" |
| ... | ... | @@ -3375,6 +4133,159 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 3375 | 4133 | ); |
| 3376 | 4134 | }); |
| 3377 | 4135 | |
| 4136 | interface PlanModePanelProps { | |
| 4137 | activePlan: ReturnType<typeof deriveActivePlanState>; | |
| 4138 | } | |
| 4139 | ||
| 4140 | const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) { | |
| 4141 | if (!activePlan) return null; | |
| 4142 | ||
| 4143 | return ( | |
| 4144 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 4145 | <div className="rounded-xl border border-border/70 bg-muted/30 p-4"> | |
| 4146 | <div className="flex items-center gap-2"> | |
| 4147 | <Badge variant="secondary">Plan</Badge> | |
| 4148 | <span className="text-xs text-muted-foreground"> | |
| 4149 | Updated {formatTimestamp(activePlan.createdAt)} | |
| 4150 | </span> | |
| 4151 | </div> | |
| 4152 | {activePlan.explanation ? ( | |
| 4153 | <p className="mt-2 text-sm text-muted-foreground">{activePlan.explanation}</p> | |
| 4154 | ) : null} | |
| 4155 | <div className="mt-3 space-y-2"> | |
| 4156 | {activePlan.steps.map((step) => ( | |
| 4157 | <div | |
| 4158 | key={`${step.status}:${step.step}`} | |
| 4159 | className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/80 px-3 py-2" | |
| 4160 | > | |
| 4161 | <Badge | |
| 4162 | variant={ | |
| 4163 | step.status === "completed" | |
| 4164 | ? "default" | |
| 4165 | : step.status === "inProgress" | |
| 4166 | ? "secondary" | |
| 4167 | : "outline" | |
| 4168 | } | |
| 4169 | > | |
| 4170 | {step.status === "inProgress" | |
| 4171 | ? "In progress" | |
| 4172 | : step.status === "completed" | |
| 4173 | ? "Done" | |
| 4174 | : "Pending"} | |
| 4175 | </Badge> | |
| 4176 | <div className="min-w-0 flex-1 text-sm">{step.step}</div> | |
| 4177 | </div> | |
| 4178 | ))} | |
| 4179 | </div> | |
| 4180 | </div> | |
| 4181 | </div> | |
| 4182 | ); | |
| 4183 | }); | |
| 4184 | ||
| 4185 | interface PendingUserInputPanelProps { | |
| 4186 | pendingUserInputs: PendingUserInput[]; | |
| 4187 | respondingRequestIds: ApprovalRequestId[]; | |
| 4188 | answers: Record<string, PendingUserInputDraftAnswer>; | |
| 4189 | questionIndex: number; | |
| 4190 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 4191 | } | |
| 4192 | ||
| 4193 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ | |
| 4194 | pendingUserInputs, | |
| 4195 | respondingRequestIds, | |
| 4196 | answers, | |
| 4197 | questionIndex, | |
| 4198 | onSelectOption, | |
| 4199 | }: PendingUserInputPanelProps) { | |
| 4200 | if (pendingUserInputs.length === 0) return null; | |
| 4201 | const activePrompt = pendingUserInputs[0]; | |
| 4202 | if (!activePrompt) return null; | |
| 4203 | ||
| 4204 | return ( | |
| 4205 | <ComposerPendingUserInputCard | |
| 4206 | key={activePrompt.requestId} | |
| 4207 | prompt={activePrompt} | |
| 4208 | pendingPromptCount={pendingUserInputs.length} | |
| 4209 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} | |
| 4210 | answers={answers} | |
| 4211 | questionIndex={questionIndex} | |
| 4212 | onSelectOption={onSelectOption} | |
| 4213 | /> | |
| 4214 | ); | |
| 4215 | }); | |
| 4216 | ||
| 4217 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ | |
| 4218 | prompt, | |
| 4219 | pendingPromptCount, | |
| 4220 | isResponding, | |
| 4221 | answers, | |
| 4222 | questionIndex, | |
| 4223 | onSelectOption, | |
| 4224 | }: { | |
| 4225 | prompt: PendingUserInput; | |
| 4226 | pendingPromptCount: number; | |
| 4227 | isResponding: boolean; | |
| 4228 | answers: Record<string, PendingUserInputDraftAnswer>; | |
| 4229 | questionIndex: number; | |
| 4230 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 4231 | }) { | |
| 4232 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | |
| 4233 | const activeQuestion = progress.activeQuestion; | |
| 4234 | ||
| 4235 | if (!activeQuestion) { | |
| 4236 | return null; | |
| 4237 | } | |
| 4238 | ||
| 4239 | return ( | |
| 4240 | <div className="px-4 py-4 sm:px-5"> | |
| 4241 | <div className="flex gap-2"> | |
| 4242 | <span className="uppercase text-sm tracking-[0.2em]"> | |
| 4243 | {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} | |
| 4244 | </span> | |
| 4245 | <div className="text-sm font-medium">{activeQuestion.question}</div> | |
| 4246 | </div> | |
| 4247 | <div className="mt-3 flex flex-wrap gap-2"> | |
| 4248 | {activeQuestion.options.map((option) => { | |
| 4249 | const isSelected = progress.selectedOptionLabel === option.label; | |
| 4250 | return ( | |
| 4251 | <Button | |
| 4252 | key={`${activeQuestion.id}:${option.label}`} | |
| 4253 | size="sm" | |
| 4254 | variant={isSelected ? "default" : "outline"} | |
| 4255 | disabled={isResponding} | |
| 4256 | onClick={() => onSelectOption(activeQuestion.id, option.label)} | |
| 4257 | title={option.description} | |
| 4258 | > | |
| 4259 | {option.label} | |
| 4260 | </Button> | |
| 4261 | ); | |
| 4262 | })} | |
| 4263 | </div> | |
| 4264 | </div> | |
| 4265 | ); | |
| 4266 | }); | |
| 4267 | ||
| 4268 | const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ | |
| 4269 | planTitle, | |
| 4270 | }: { | |
| 4271 | planTitle: string | null; | |
| 4272 | }) { | |
| 4273 | return ( | |
| 4274 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 4275 | <div className="flex flex-wrap items-center gap-2"> | |
| 4276 | <Badge variant="secondary">Plan ready</Badge> | |
| 4277 | {planTitle ? ( | |
| 4278 | <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{planTitle}</span> | |
| 4279 | ) : null} | |
| 4280 | </div> | |
| 4281 | <div className="mt-2 text-xs text-muted-foreground"> | |
| 4282 | Leave the composer empty to implement here, use the menu to implement in a new thread, or | |
| 4283 | type feedback to refine it. | |
| 4284 | </div> | |
| 4285 | </div> | |
| 4286 | ); | |
| 4287 | }); | |
| 4288 | ||
| 3378 | 4289 | const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { |
| 3379 | 4290 | const [copied, setCopied] = useState(false); |
| 3380 | 4291 | |
| ... | ... | @@ -3537,6 +4448,191 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3537 | 4448 | return ( |
| 3538 | 4449 | <div className="space-y-0.5"> |
| 3539 | 4450 | {treeNodes.map((node) => renderTreeNode(node, 0))} |
| 4451 | const ProposedPlanMessage = memo(function ProposedPlanMessage({ | |
| 4452 | text, | |
| 4453 | cwd, | |
| 4454 | workspaceRoot, | |
| 4455 | isStreaming, | |
| 4456 | }: { | |
| 4457 | text: string; | |
| 4458 | cwd: string | undefined; | |
| 4459 | workspaceRoot: string | undefined; | |
| 4460 | isStreaming: boolean; | |
| 4461 | }) { | |
| 4462 | const proposedPlan = parseProposedPlanMessage(text); | |
| 4463 | const [expanded, setExpanded] = useState(false); | |
| 4464 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); | |
| 4465 | const [savePath, setSavePath] = useState(""); | |
| 4466 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | |
| 4467 | const savePathInputId = useId(); | |
| 4468 | ||
| 4469 | if (!proposedPlan) { | |
| 4470 | return <ChatMarkdown text={text} cwd={cwd} isStreaming={isStreaming} />; | |
| 4471 | } | |
| 4472 | ||
| 4473 | const title = proposedPlanTitle(proposedPlan.planMarkdown) ?? "Proposed plan"; | |
| 4474 | const lineCount = proposedPlan.planMarkdown.split("\n").length; | |
| 4475 | const canCollapse = proposedPlan.planMarkdown.length > 900 || lineCount > 20; | |
| 4476 | const downloadFilename = buildProposedPlanMarkdownFilename(proposedPlan.planMarkdown); | |
| 4477 | const saveContents = normalizePlanMarkdownForExport(proposedPlan.planMarkdown); | |
| 4478 | ||
| 4479 | const handleDownload = () => { | |
| 4480 | downloadTextFile(downloadFilename, saveContents); | |
| 4481 | }; | |
| 4482 | ||
| 4483 | const openSaveDialog = () => { | |
| 4484 | if (!workspaceRoot) { | |
| 4485 | toastManager.add({ | |
| 4486 | type: "error", | |
| 4487 | title: "Workspace path is unavailable", | |
| 4488 | description: "This thread does not have a workspace path to save into.", | |
| 4489 | }); | |
| 4490 | return; | |
| 4491 | } | |
| 4492 | setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); | |
| 4493 | setIsSaveDialogOpen(true); | |
| 4494 | }; | |
| 4495 | ||
| 4496 | const handleSaveToWorkspace = () => { | |
| 4497 | const api = readNativeApi(); | |
| 4498 | const relativePath = savePath.trim(); | |
| 4499 | if (!api || !workspaceRoot) { | |
| 4500 | return; | |
| 4501 | } | |
| 4502 | if (!relativePath) { | |
| 4503 | toastManager.add({ | |
| 4504 | type: "warning", | |
| 4505 | title: "Enter a workspace path", | |
| 4506 | }); | |
| 4507 | return; | |
| 4508 | } | |
| 4509 | ||
| 4510 | setIsSavingToWorkspace(true); | |
| 4511 | void api.projects | |
| 4512 | .writeFile({ | |
| 4513 | cwd: workspaceRoot, | |
| 4514 | relativePath, | |
| 4515 | contents: saveContents, | |
| 4516 | }) | |
| 4517 | .then((result) => { | |
| 4518 | setIsSaveDialogOpen(false); | |
| 4519 | toastManager.add({ | |
| 4520 | type: "success", | |
| 4521 | title: "Plan saved to workspace", | |
| 4522 | description: result.relativePath, | |
| 4523 | }); | |
| 4524 | }) | |
| 4525 | .catch((error) => { | |
| 4526 | toastManager.add({ | |
| 4527 | type: "error", | |
| 4528 | title: "Could not save plan", | |
| 4529 | description: error instanceof Error ? error.message : "An error occurred while saving.", | |
| 4530 | }); | |
| 4531 | }) | |
| 4532 | .then( | |
| 4533 | () => { | |
| 4534 | setIsSavingToWorkspace(false); | |
| 4535 | }, | |
| 4536 | () => { | |
| 4537 | setIsSavingToWorkspace(false); | |
| 4538 | }, | |
| 4539 | ); | |
| 4540 | }; | |
| 4541 | ||
| 4542 | return ( | |
| 4543 | <div className="space-y-3"> | |
| 4544 | {proposedPlan.beforeText ? ( | |
| 4545 | <ChatMarkdown text={proposedPlan.beforeText} cwd={cwd} isStreaming={isStreaming} /> | |
| 4546 | ) : null} | |
| 4547 | ||
| 4548 | <div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5"> | |
| 4549 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 4550 | <div className="flex min-w-0 items-center gap-2"> | |
| 4551 | <Badge variant="secondary">Plan</Badge> | |
| 4552 | <p className="truncate text-sm font-medium text-foreground">{title}</p> | |
| 4553 | </div> | |
| 4554 | <Menu> | |
| 4555 | <MenuTrigger | |
| 4556 | render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />} | |
| 4557 | > | |
| 4558 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 4559 | </MenuTrigger> | |
| 4560 | <MenuPopup align="end"> | |
| 4561 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 4562 | <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> | |
| 4563 | Save to workspace | |
| 4564 | </MenuItem> | |
| 4565 | </MenuPopup> | |
| 4566 | </Menu> | |
| 4567 | </div> | |
| 4568 | <div className="mt-4"> | |
| 4569 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> | |
| 4570 | <ChatMarkdown text={proposedPlan.planMarkdown} cwd={cwd} isStreaming={false} /> | |
| 4571 | {canCollapse && !expanded ? ( | |
| 4572 | <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> | |
| 4573 | ) : null} | |
| 4574 | </div> | |
| 4575 | {canCollapse ? ( | |
| 4576 | <div className="mt-4 flex justify-center"> | |
| 4577 | <Button size="sm" variant="outline" onClick={() => setExpanded((value) => !value)}> | |
| 4578 | {expanded ? "Collapse plan" : "Expand plan"} | |
| 4579 | </Button> | |
| 4580 | </div> | |
| 4581 | ) : null} | |
| 4582 | </div> | |
| 4583 | </div> | |
| 4584 | ||
| 4585 | <Dialog | |
| 4586 | open={isSaveDialogOpen} | |
| 4587 | onOpenChange={(open) => { | |
| 4588 | if (!isSavingToWorkspace) { | |
| 4589 | setIsSaveDialogOpen(open); | |
| 4590 | } | |
| 4591 | }} | |
| 4592 | > | |
| 4593 | <DialogPopup className="max-w-xl"> | |
| 4594 | <DialogHeader> | |
| 4595 | <DialogTitle>Save plan to workspace</DialogTitle> | |
| 4596 | <DialogDescription> | |
| 4597 | Enter a path relative to <code>{workspaceRoot ?? "the workspace"}</code>. | |
| 4598 | </DialogDescription> | |
| 4599 | </DialogHeader> | |
| 4600 | <DialogPanel className="space-y-3"> | |
| 4601 | <label htmlFor={savePathInputId} className="grid gap-1.5"> | |
| 4602 | <span className="text-xs font-medium text-foreground">Workspace path</span> | |
| 4603 | <Input | |
| 4604 | id={savePathInputId} | |
| 4605 | value={savePath} | |
| 4606 | onChange={(event) => setSavePath(event.target.value)} | |
| 4607 | placeholder={downloadFilename} | |
| 4608 | spellCheck={false} | |
| 4609 | disabled={isSavingToWorkspace} | |
| 4610 | /> | |
| 4611 | </label> | |
| 4612 | </DialogPanel> | |
| 4613 | <DialogFooter> | |
| 4614 | <Button | |
| 4615 | variant="outline" | |
| 4616 | size="sm" | |
| 4617 | onClick={() => setIsSaveDialogOpen(false)} | |
| 4618 | disabled={isSavingToWorkspace} | |
| 4619 | > | |
| 4620 | Cancel | |
| 4621 | </Button> | |
| 4622 | <Button | |
| 4623 | size="sm" | |
| 4624 | onClick={() => void handleSaveToWorkspace()} | |
| 4625 | disabled={isSavingToWorkspace} | |
| 4626 | > | |
| 4627 | {isSavingToWorkspace ? "Saving..." : "Save"} | |
| 4628 | </Button> | |
| 4629 | </DialogFooter> | |
| 4630 | </DialogPopup> | |
| 4631 | </Dialog> | |
| 4632 | ||
| 4633 | {proposedPlan.afterText ? ( | |
| 4634 | <ChatMarkdown text={proposedPlan.afterText} cwd={cwd} isStreaming={isStreaming} /> | |
| 4635 | ) : null} | |
| 3540 | 4636 | </div> |
| 3541 | 4637 | ); |
| 3542 | 4638 | }); |
| ... | ... | @@ -3561,6 +4657,7 @@ interface MessagesTimelineProps { |
| 3561 | 4657 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 3562 | 4658 | markdownCwd: string | undefined; |
| 3563 | 4659 | resolvedTheme: "light" | "dark"; |
| 4660 | workspaceRoot: string | undefined; | |
| 3564 | 4661 | } |
| 3565 | 4662 | |
| 3566 | 4663 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| ... | ... | @@ -3602,6 +4699,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3602 | 4699 | onImageExpand, |
| 3603 | 4700 | markdownCwd, |
| 3604 | 4701 | resolvedTheme, |
| 4702 | workspaceRoot, | |
| 3605 | 4703 | }: MessagesTimelineProps) { |
| 3606 | 4704 | const timelineRootRef = useRef<HTMLDivElement | null>(null); |
| 3607 | 4705 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); |
| ... | ... | @@ -3946,9 +5044,10 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3946 | 5044 | </div> |
| 3947 | 5045 | )} |
| 3948 | 5046 | <div className="min-w-0 px-1 py-0.5"> |
| 3949 | <ChatMarkdown | |
| 5047 | <ProposedPlanMessage | |
| 3950 | 5048 | text={messageText} |
| 3951 | 5049 | cwd={markdownCwd} |
| 5050 | workspaceRoot={workspaceRoot} | |
| 3952 | 5051 | isStreaming={Boolean(row.message.streaming)} |
| 3953 | 5052 | /> |
| 3954 | 5053 | {(() => { |
| ... | ... | @@ -4201,7 +5300,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 4201 | 5300 | return ( |
| 4202 | 5301 | <MenuSub key={option.value}> |
| 4203 | 5302 | <MenuSubTrigger disabled={isDisabledByProviderLock}> |
| 4204 | <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" /> | |
| 5303 | <OptionIcon | |
| 5304 | aria-hidden="true" | |
| 5305 | className="size-4 shrink-0 text-muted-foreground/85" | |
| 5306 | /> | |
| 4205 | 5307 | {option.label} |
| 4206 | 5308 | </MenuSubTrigger> |
| 4207 | 5309 | <MenuSubPopup className="[--available-height:min(24rem,70vh)]"> |
| ... | ... | @@ -4354,7 +5456,9 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { |
| 4354 | 5456 | </MenuGroup> |
| 4355 | 5457 | )} |
| 4356 | 5458 | {props.capabilities.supportsReasoning && |
| 4357 | (props.capabilities.supportsFast || props.capabilities.supportsThinking) && <MenuDivider />} | |
| 5459 | (props.capabilities.supportsFast || props.capabilities.supportsThinking) && ( | |
| 5460 | <MenuDivider /> | |
| 5461 | )} | |
| 4358 | 5462 | {props.capabilities.supportsFast && ( |
| 4359 | 5463 | <MenuGroup> |
| 4360 | 5464 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> |
apps/web/src/composer-logic.test.ts+27| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | detectComposerTrigger, |
| 5 | 5 | expandCollapsedComposerCursor, |
| 6 | 6 | isCollapsedCursorAdjacentToMention, |
| 7 | parseStandaloneComposerSlashCommand, | |
| 7 | 8 | replaceTextRange, |
| 8 | 9 | } from "./composer-logic"; |
| 9 | 10 | |
| ... | ... | @@ -43,6 +44,18 @@ describe("detectComposerTrigger", () => { |
| 43 | 44 | rangeEnd: text.length, |
| 44 | 45 | }); |
| 45 | 46 | }); |
| 47 | ||
| 48 | it("detects non-model slash commands while typing", () => { | |
| 49 | const text = "/pl"; | |
| 50 | const trigger = detectComposerTrigger(text, text.length); | |
| 51 | ||
| 52 | expect(trigger).toEqual({ | |
| 53 | kind: "slash-command", | |
| 54 | query: "pl", | |
| 55 | rangeStart: 0, | |
| 56 | rangeEnd: text.length, | |
| 57 | }); | |
| 58 | }); | |
| 46 | 59 | }); |
| 47 | 60 | |
| 48 | 61 | describe("replaceTextRange", () => { |
| ... | ... | @@ -111,3 +124,17 @@ describe("isCollapsedCursorAdjacentToMention", () => { |
| 111 | 124 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); |
| 112 | 125 | }); |
| 113 | 126 | }); |
| 127 | ||
| 128 | describe("parseStandaloneComposerSlashCommand", () => { | |
| 129 | it("parses standalone /plan command", () => { | |
| 130 | expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan"); | |
| 131 | }); | |
| 132 | ||
| 133 | it("parses standalone /default command", () => { | |
| 134 | expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); | |
| 135 | }); | |
| 136 | ||
| 137 | it("ignores slash commands with extra message text", () => { | |
| 138 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); | |
| 139 | }); | |
| 140 | }); |
apps/web/src/composer-logic.ts+15-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | 2 | |
| 3 | 3 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; |
| 4 | export type ComposerSlashCommand = "model" | "plan" | "default"; | |
| 4 | 5 | |
| 5 | 6 | export interface ComposerTrigger { |
| 6 | 7 | kind: ComposerTriggerKind; |
| ... | ... | @@ -9,6 +10,8 @@ export interface ComposerTrigger { |
| 9 | 10 | rangeEnd: number; |
| 10 | 11 | } |
| 11 | 12 | |
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; | |
| 14 | ||
| 12 | 15 | function clampCursor(text: string, cursor: number): number { |
| 13 | 16 | if (!Number.isFinite(cursor)) return text.length; |
| 14 | 17 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); |
| ... | ... | @@ -121,7 +124,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 121 | 124 | rangeEnd: cursor, |
| 122 | 125 | }; |
| 123 | 126 | } |
| 124 | if ("model".startsWith(commandQuery.toLowerCase())) { | |
| 127 | if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { | |
| 125 | 128 | return { |
| 126 | 129 | kind: "slash-command", |
| 127 | 130 | query: commandQuery, |
| ... | ... | @@ -157,6 +160,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 157 | 160 | }; |
| 158 | 161 | } |
| 159 | 162 | |
| 163 | export function parseStandaloneComposerSlashCommand(text: string): Exclude< | |
| 164 | ComposerSlashCommand, | |
| 165 | "model" | |
| 166 | > | null { | |
| 167 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | |
| 168 | if (!match) { | |
| 169 | return null; | |
| 170 | } | |
| 171 | return match[1]?.toLowerCase() === "plan" ? "plan" : "default"; | |
| 172 | } | |
| 173 | ||
| 160 | 174 | export function replaceTextRange( |
| 161 | 175 | text: string, |
| 162 | 176 | rangeStart: number, |
apps/web/src/composerDraftStore.ts+23-1| ... | ... | @@ -6,9 +6,14 @@ import { |
| 6 | 6 | normalizeModelSlug, |
| 7 | 7 | type CodexReasoningEffort, |
| 8 | 8 | type ProviderKind, |
| 9 | type ProviderInteractionMode, | |
| 9 | 10 | type RuntimeMode, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; | |
| 12 | import { | |
| 13 | DEFAULT_INTERACTION_MODE, | |
| 14 | DEFAULT_RUNTIME_MODE, | |
| 15 | type ChatImageAttachment, | |
| 16 | } from "./types"; | |
| 12 | 17 | import { create } from "zustand"; |
| 13 | 18 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 14 | 19 | |
| ... | ... | @@ -40,6 +45,7 @@ interface PersistedDraftThreadState { |
| 40 | 45 | projectId: ProjectId; |
| 41 | 46 | createdAt: string; |
| 42 | 47 | runtimeMode: RuntimeMode; |
| 48 | interactionMode: ProviderInteractionMode; | |
| 43 | 49 | branch: string | null; |
| 44 | 50 | worktreePath: string | null; |
| 45 | 51 | envMode: DraftThreadEnvMode; |
| ... | ... | @@ -65,6 +71,7 @@ export interface DraftThreadState { |
| 65 | 71 | projectId: ProjectId; |
| 66 | 72 | createdAt: string; |
| 67 | 73 | runtimeMode: RuntimeMode; |
| 74 | interactionMode: ProviderInteractionMode; | |
| 68 | 75 | branch: string | null; |
| 69 | 76 | worktreePath: string | null; |
| 70 | 77 | envMode: DraftThreadEnvMode; |
| ... | ... | @@ -89,6 +96,7 @@ interface ComposerDraftStoreState { |
| 89 | 96 | createdAt?: string; |
| 90 | 97 | envMode?: DraftThreadEnvMode; |
| 91 | 98 | runtimeMode?: RuntimeMode; |
| 99 | interactionMode?: ProviderInteractionMode; | |
| 92 | 100 | }, |
| 93 | 101 | ) => void; |
| 94 | 102 | setDraftThreadContext: ( |
| ... | ... | @@ -100,6 +108,7 @@ interface ComposerDraftStoreState { |
| 100 | 108 | createdAt?: string; |
| 101 | 109 | envMode?: DraftThreadEnvMode; |
| 102 | 110 | runtimeMode?: RuntimeMode; |
| 111 | interactionMode?: ProviderInteractionMode; | |
| 103 | 112 | }, |
| 104 | 113 | ) => void; |
| 105 | 114 | clearProjectDraftThreadId: (projectId: ProjectId) => void; |
| ... | ... | @@ -270,6 +279,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 270 | 279 | candidateDraftThread.runtimeMode === "full-access" |
| 271 | 280 | ? candidateDraftThread.runtimeMode |
| 272 | 281 | : DEFAULT_RUNTIME_MODE, |
| 282 | interactionMode: | |
| 283 | candidateDraftThread.interactionMode === "plan" || | |
| 284 | candidateDraftThread.interactionMode === "default" | |
| 285 | ? candidateDraftThread.interactionMode | |
| 286 | : DEFAULT_INTERACTION_MODE, | |
| 273 | 287 | branch: typeof branch === "string" ? branch : null, |
| 274 | 288 | worktreePath: normalizedWorktreePath, |
| 275 | 289 | envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), |
| ... | ... | @@ -297,6 +311,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 297 | 311 | projectId: projectId as ProjectId, |
| 298 | 312 | createdAt: new Date().toISOString(), |
| 299 | 313 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 314 | interactionMode: DEFAULT_INTERACTION_MODE, | |
| 300 | 315 | branch: null, |
| 301 | 316 | worktreePath: null, |
| 302 | 317 | envMode: "local", |
| ... | ... | @@ -500,6 +515,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 500 | 515 | projectId, |
| 501 | 516 | createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), |
| 502 | 517 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 518 | interactionMode: | |
| 519 | options?.interactionMode ?? | |
| 520 | existingThread?.interactionMode ?? | |
| 521 | DEFAULT_INTERACTION_MODE, | |
| 503 | 522 | branch: |
| 504 | 523 | options?.branch === undefined |
| 505 | 524 | ? (existingThread?.branch ?? null) |
| ... | ... | @@ -515,6 +534,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 515 | 534 | existingThread.projectId === nextDraftThread.projectId && |
| 516 | 535 | existingThread.createdAt === nextDraftThread.createdAt && |
| 517 | 536 | existingThread.runtimeMode === nextDraftThread.runtimeMode && |
| 537 | existingThread.interactionMode === nextDraftThread.interactionMode && | |
| 518 | 538 | existingThread.branch === nextDraftThread.branch && |
| 519 | 539 | existingThread.worktreePath === nextDraftThread.worktreePath && |
| 520 | 540 | existingThread.envMode === nextDraftThread.envMode; |
| ... | ... | @@ -570,6 +590,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 570 | 590 | ? existing.createdAt |
| 571 | 591 | : options.createdAt || existing.createdAt, |
| 572 | 592 | runtimeMode: options.runtimeMode ?? existing.runtimeMode, |
| 593 | interactionMode: options.interactionMode ?? existing.interactionMode, | |
| 573 | 594 | branch: options.branch === undefined ? existing.branch : (options.branch ?? null), |
| 574 | 595 | worktreePath: nextWorktreePath, |
| 575 | 596 | envMode: |
| ... | ... | @@ -580,6 +601,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 580 | 601 | nextDraftThread.projectId === existing.projectId && |
| 581 | 602 | nextDraftThread.createdAt === existing.createdAt && |
| 582 | 603 | nextDraftThread.runtimeMode === existing.runtimeMode && |
| 604 | nextDraftThread.interactionMode === existing.interactionMode && | |
| 583 | 605 | nextDraftThread.branch === existing.branch && |
| 584 | 606 | nextDraftThread.worktreePath === existing.worktreePath && |
| 585 | 607 | nextDraftThread.envMode === existing.envMode; |
apps/web/src/pendingUserInput.test.ts created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | buildPendingUserInputAnswers, | |
| 5 | countAnsweredPendingUserInputQuestions, | |
| 6 | derivePendingUserInputProgress, | |
| 7 | findFirstUnansweredPendingUserInputQuestionIndex, | |
| 8 | resolvePendingUserInputAnswer, | |
| 9 | setPendingUserInputCustomAnswer, | |
| 10 | } from "./pendingUserInput"; | |
| 11 | ||
| 12 | describe("resolvePendingUserInputAnswer", () => { | |
| 13 | it("prefers a custom answer over a selected option", () => { | |
| 14 | expect( | |
| 15 | resolvePendingUserInputAnswer({ | |
| 16 | selectedOptionLabel: "Keep current envelope", | |
| 17 | customAnswer: "Keep the existing envelope for one release", | |
| 18 | }), | |
| 19 | ).toBe("Keep the existing envelope for one release"); | |
| 20 | }); | |
| 21 | ||
| 22 | it("falls back to the selected option", () => { | |
| 23 | expect( | |
| 24 | resolvePendingUserInputAnswer({ | |
| 25 | selectedOptionLabel: "Scaffold only", | |
| 26 | }), | |
| 27 | ).toBe("Scaffold only"); | |
| 28 | }); | |
| 29 | ||
| 30 | it("clears the preset selection when a custom answer is entered", () => { | |
| 31 | expect( | |
| 32 | setPendingUserInputCustomAnswer( | |
| 33 | { | |
| 34 | selectedOptionLabel: "Preserve existing tags", | |
| 35 | }, | |
| 36 | "doesn't matter", | |
| 37 | ), | |
| 38 | ).toEqual({ | |
| 39 | selectedOptionLabel: undefined, | |
| 40 | customAnswer: "doesn't matter", | |
| 41 | }); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("buildPendingUserInputAnswers", () => { | |
| 46 | it("returns a canonical answer map for complete prompts", () => { | |
| 47 | expect( | |
| 48 | buildPendingUserInputAnswers( | |
| 49 | [ | |
| 50 | { | |
| 51 | id: "scope", | |
| 52 | header: "Scope", | |
| 53 | question: "What should the plan target first?", | |
| 54 | options: [ | |
| 55 | { | |
| 56 | label: "Orchestration-first", | |
| 57 | description: "Focus on orchestration first", | |
| 58 | }, | |
| 59 | ], | |
| 60 | }, | |
| 61 | { | |
| 62 | id: "compat", | |
| 63 | header: "Compat", | |
| 64 | question: "How strict should compatibility be?", | |
| 65 | options: [ | |
| 66 | { | |
| 67 | label: "Keep current envelope", | |
| 68 | description: "Preserve current wire format", | |
| 69 | }, | |
| 70 | ], | |
| 71 | }, | |
| 72 | ], | |
| 73 | { | |
| 74 | scope: { | |
| 75 | selectedOptionLabel: "Orchestration-first", | |
| 76 | }, | |
| 77 | compat: { | |
| 78 | customAnswer: "Keep the current envelope for one release window", | |
| 79 | }, | |
| 80 | }, | |
| 81 | ), | |
| 82 | ).toEqual({ | |
| 83 | scope: "Orchestration-first", | |
| 84 | compat: "Keep the current envelope for one release window", | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | it("returns null when any question is unanswered", () => { | |
| 89 | expect( | |
| 90 | buildPendingUserInputAnswers( | |
| 91 | [ | |
| 92 | { | |
| 93 | id: "scope", | |
| 94 | header: "Scope", | |
| 95 | question: "What should the plan target first?", | |
| 96 | options: [ | |
| 97 | { | |
| 98 | label: "Orchestration-first", | |
| 99 | description: "Focus on orchestration first", | |
| 100 | }, | |
| 101 | ], | |
| 102 | }, | |
| 103 | ], | |
| 104 | {}, | |
| 105 | ), | |
| 106 | ).toBeNull(); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | describe("pending user input question progress", () => { | |
| 111 | const questions = [ | |
| 112 | { | |
| 113 | id: "scope", | |
| 114 | header: "Scope", | |
| 115 | question: "What should the plan target first?", | |
| 116 | options: [ | |
| 117 | { | |
| 118 | label: "Orchestration-first", | |
| 119 | description: "Focus on orchestration first", | |
| 120 | }, | |
| 121 | ], | |
| 122 | }, | |
| 123 | { | |
| 124 | id: "compat", | |
| 125 | header: "Compat", | |
| 126 | question: "How strict should compatibility be?", | |
| 127 | options: [ | |
| 128 | { | |
| 129 | label: "Keep current envelope", | |
| 130 | description: "Preserve current wire format", | |
| 131 | }, | |
| 132 | ], | |
| 133 | }, | |
| 134 | ] as const; | |
| 135 | ||
| 136 | it("counts only answered questions", () => { | |
| 137 | expect( | |
| 138 | countAnsweredPendingUserInputQuestions(questions, { | |
| 139 | scope: { | |
| 140 | selectedOptionLabel: "Orchestration-first", | |
| 141 | }, | |
| 142 | }), | |
| 143 | ).toBe(1); | |
| 144 | }); | |
| 145 | ||
| 146 | it("finds the first unanswered question", () => { | |
| 147 | expect( | |
| 148 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { | |
| 149 | scope: { | |
| 150 | selectedOptionLabel: "Orchestration-first", | |
| 151 | }, | |
| 152 | }), | |
| 153 | ).toBe(1); | |
| 154 | }); | |
| 155 | ||
| 156 | it("returns the last question index when all answers are complete", () => { | |
| 157 | expect( | |
| 158 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { | |
| 159 | scope: { | |
| 160 | selectedOptionLabel: "Orchestration-first", | |
| 161 | }, | |
| 162 | compat: { | |
| 163 | customAnswer: "Keep it for one release window", | |
| 164 | }, | |
| 165 | }), | |
| 166 | ).toBe(1); | |
| 167 | }); | |
| 168 | ||
| 169 | it("derives the active question and advancement state", () => { | |
| 170 | expect( | |
| 171 | derivePendingUserInputProgress( | |
| 172 | questions, | |
| 173 | { | |
| 174 | scope: { | |
| 175 | selectedOptionLabel: "Orchestration-first", | |
| 176 | }, | |
| 177 | }, | |
| 178 | 0, | |
| 179 | ), | |
| 180 | ).toMatchObject({ | |
| 181 | questionIndex: 0, | |
| 182 | activeQuestion: questions[0], | |
| 183 | selectedOptionLabel: "Orchestration-first", | |
| 184 | customAnswer: "", | |
| 185 | resolvedAnswer: "Orchestration-first", | |
| 186 | answeredQuestionCount: 1, | |
| 187 | isLastQuestion: false, | |
| 188 | isComplete: false, | |
| 189 | canAdvance: true, | |
| 190 | }); | |
| 191 | }); | |
| 192 | }); |
apps/web/src/pendingUserInput.ts created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | import type { UserInputQuestion } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface PendingUserInputDraftAnswer { | |
| 4 | selectedOptionLabel?: string; | |
| 5 | customAnswer?: string; | |
| 6 | } | |
| 7 | ||
| 8 | export interface PendingUserInputProgress { | |
| 9 | questionIndex: number; | |
| 10 | activeQuestion: UserInputQuestion | null; | |
| 11 | activeDraft: PendingUserInputDraftAnswer | undefined; | |
| 12 | selectedOptionLabel: string | undefined; | |
| 13 | customAnswer: string; | |
| 14 | resolvedAnswer: string | null; | |
| 15 | usingCustomAnswer: boolean; | |
| 16 | answeredQuestionCount: number; | |
| 17 | isLastQuestion: boolean; | |
| 18 | isComplete: boolean; | |
| 19 | canAdvance: boolean; | |
| 20 | } | |
| 21 | ||
| 22 | function normalizeDraftAnswer(value: string | undefined): string | null { | |
| 23 | if (typeof value !== "string") { | |
| 24 | return null; | |
| 25 | } | |
| 26 | ||
| 27 | const trimmed = value.trim(); | |
| 28 | return trimmed.length > 0 ? trimmed : null; | |
| 29 | } | |
| 30 | ||
| 31 | export function resolvePendingUserInputAnswer( | |
| 32 | draft: PendingUserInputDraftAnswer | undefined, | |
| 33 | ): string | null { | |
| 34 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); | |
| 35 | if (customAnswer) { | |
| 36 | return customAnswer; | |
| 37 | } | |
| 38 | ||
| 39 | return normalizeDraftAnswer(draft?.selectedOptionLabel); | |
| 40 | } | |
| 41 | ||
| 42 | export function setPendingUserInputCustomAnswer( | |
| 43 | draft: PendingUserInputDraftAnswer | undefined, | |
| 44 | customAnswer: string, | |
| 45 | ): PendingUserInputDraftAnswer { | |
| 46 | const selectedOptionLabel = | |
| 47 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; | |
| 48 | ||
| 49 | return { | |
| 50 | customAnswer, | |
| 51 | ...(selectedOptionLabel ? { selectedOptionLabel } : {}), | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | export function buildPendingUserInputAnswers( | |
| 56 | questions: ReadonlyArray<UserInputQuestion>, | |
| 57 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | |
| 58 | ): Record<string, string> | null { | |
| 59 | const answers: Record<string, string> = {}; | |
| 60 | ||
| 61 | for (const question of questions) { | |
| 62 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); | |
| 63 | if (!answer) { | |
| 64 | return null; | |
| 65 | } | |
| 66 | answers[question.id] = answer; | |
| 67 | } | |
| 68 | ||
| 69 | return answers; | |
| 70 | } | |
| 71 | ||
| 72 | export function countAnsweredPendingUserInputQuestions( | |
| 73 | questions: ReadonlyArray<UserInputQuestion>, | |
| 74 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | |
| 75 | ): number { | |
| 76 | return questions.reduce((count, question) => { | |
| 77 | return resolvePendingUserInputAnswer(draftAnswers[question.id]) ? count + 1 : count; | |
| 78 | }, 0); | |
| 79 | } | |
| 80 | ||
| 81 | export function findFirstUnansweredPendingUserInputQuestionIndex( | |
| 82 | questions: ReadonlyArray<UserInputQuestion>, | |
| 83 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | |
| 84 | ): number { | |
| 85 | const unansweredIndex = questions.findIndex( | |
| 86 | (question) => !resolvePendingUserInputAnswer(draftAnswers[question.id]), | |
| 87 | ); | |
| 88 | ||
| 89 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; | |
| 90 | } | |
| 91 | ||
| 92 | export function derivePendingUserInputProgress( | |
| 93 | questions: ReadonlyArray<UserInputQuestion>, | |
| 94 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | |
| 95 | questionIndex: number, | |
| 96 | ): PendingUserInputProgress { | |
| 97 | const normalizedQuestionIndex = | |
| 98 | questions.length === 0 | |
| 99 | ? 0 | |
| 100 | : Math.max(0, Math.min(questionIndex, questions.length - 1)); | |
| 101 | const activeQuestion = questions[normalizedQuestionIndex] ?? null; | |
| 102 | const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; | |
| 103 | const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); | |
| 104 | const customAnswer = activeDraft?.customAnswer ?? ""; | |
| 105 | const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); | |
| 106 | const isLastQuestion = | |
| 107 | questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; | |
| 108 | ||
| 109 | return { | |
| 110 | questionIndex: normalizedQuestionIndex, | |
| 111 | activeQuestion, | |
| 112 | activeDraft, | |
| 113 | selectedOptionLabel: activeDraft?.selectedOptionLabel, | |
| 114 | customAnswer, | |
| 115 | resolvedAnswer, | |
| 116 | usingCustomAnswer: customAnswer.trim().length > 0, | |
| 117 | answeredQuestionCount, | |
| 118 | isLastQuestion, | |
| 119 | isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, | |
| 120 | canAdvance: Boolean(resolvedAnswer), | |
| 121 | }; | |
| 122 | } |
apps/web/src/proposedPlan.test.ts created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | buildPlanImplementationThreadTitle, | |
| 6 | buildPlanImplementationPrompt, | |
| 7 | buildProposedPlanMarkdownFilename, | |
| 8 | findLatestProposedPlanMessage, | |
| 9 | parseProposedPlanMessage, | |
| 10 | proposedPlanTitle, | |
| 11 | } from "./proposedPlan"; | |
| 12 | ||
| 13 | describe("parseProposedPlanMessage", () => { | |
| 14 | it("extracts the proposed plan block and surrounding assistant text", () => { | |
| 15 | expect( | |
| 16 | parseProposedPlanMessage( | |
| 17 | "First I checked the repo.\n\n<proposed_plan>\n# Ship plan\n\n- step 1\n</proposed_plan>\n\nImplement this plan?", | |
| 18 | ), | |
| 19 | ).toEqual({ | |
| 20 | beforeText: "First I checked the repo.", | |
| 21 | planMarkdown: "# Ship plan\n\n- step 1", | |
| 22 | afterText: "Implement this plan?", | |
| 23 | }); | |
| 24 | }); | |
| 25 | ||
| 26 | it("returns null when the assistant message has no plan block", () => { | |
| 27 | expect(parseProposedPlanMessage("Plain assistant text")).toBeNull(); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | describe("findLatestProposedPlanMessage", () => { | |
| 32 | it("prefers the latest turn assistant message id when it contains a proposed plan", () => { | |
| 33 | const match = findLatestProposedPlanMessage( | |
| 34 | [ | |
| 35 | { | |
| 36 | id: MessageId.makeUnsafe("assistant:older-plan"), | |
| 37 | role: "assistant", | |
| 38 | text: "<proposed_plan>\n# Older plan\n</proposed_plan>", | |
| 39 | createdAt: "2026-03-05T00:00:00.000Z", | |
| 40 | streaming: false, | |
| 41 | }, | |
| 42 | { | |
| 43 | id: MessageId.makeUnsafe("assistant:latest-plan"), | |
| 44 | role: "assistant", | |
| 45 | text: "<proposed_plan>\n# Latest plan\n</proposed_plan>", | |
| 46 | createdAt: "2026-03-05T00:01:00.000Z", | |
| 47 | streaming: false, | |
| 48 | }, | |
| 49 | ], | |
| 50 | "assistant:latest-plan", | |
| 51 | ); | |
| 52 | ||
| 53 | expect(match?.message.id).toBe("assistant:latest-plan"); | |
| 54 | expect(match?.plan.planMarkdown).toBe("# Latest plan"); | |
| 55 | }); | |
| 56 | ||
| 57 | it("falls back to the latest assistant proposed plan when no latest-turn message id is available", () => { | |
| 58 | const match = findLatestProposedPlanMessage( | |
| 59 | [ | |
| 60 | { | |
| 61 | id: MessageId.makeUnsafe("assistant:plain"), | |
| 62 | role: "assistant", | |
| 63 | text: "No plan here", | |
| 64 | createdAt: "2026-03-05T00:00:00.000Z", | |
| 65 | streaming: false, | |
| 66 | }, | |
| 67 | { | |
| 68 | id: MessageId.makeUnsafe("assistant:plan"), | |
| 69 | role: "assistant", | |
| 70 | text: "<proposed_plan>\n# Fallback plan\n</proposed_plan>", | |
| 71 | createdAt: "2026-03-05T00:01:00.000Z", | |
| 72 | streaming: false, | |
| 73 | }, | |
| 74 | ], | |
| 75 | null, | |
| 76 | ); | |
| 77 | ||
| 78 | expect(match?.message.id).toBe("assistant:plan"); | |
| 79 | expect(match?.plan.planMarkdown).toBe("# Fallback plan"); | |
| 80 | }); | |
| 81 | }); | |
| 82 | ||
| 83 | describe("proposedPlanTitle", () => { | |
| 84 | it("reads the first markdown heading as the plan title", () => { | |
| 85 | expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC"); | |
| 86 | }); | |
| 87 | ||
| 88 | it("returns null when the plan has no heading", () => { | |
| 89 | expect(proposedPlanTitle("- step 1")).toBeNull(); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | describe("buildPlanImplementationPrompt", () => { | |
| 94 | it("formats the plan exactly like the Codex follow-up handoff prompt", () => { | |
| 95 | expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe( | |
| 96 | "PLEASE IMPLEMENT THIS PLAN:\n## Ship it\n\n- step 1", | |
| 97 | ); | |
| 98 | }); | |
| 99 | }); | |
| 100 | ||
| 101 | describe("buildPlanImplementationThreadTitle", () => { | |
| 102 | it("uses the plan heading when building the implementation thread title", () => { | |
| 103 | expect(buildPlanImplementationThreadTitle("# Integrate RPC\n\nBody")).toBe( | |
| 104 | "Implement Integrate RPC", | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | it("falls back when the plan has no markdown heading", () => { | |
| 109 | expect(buildPlanImplementationThreadTitle("- step 1")).toBe("Implement plan"); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("buildProposedPlanMarkdownFilename", () => { | |
| 114 | it("derives a stable markdown filename from the plan heading", () => { | |
| 115 | expect(buildProposedPlanMarkdownFilename("# Integrate Effect RPC Into Server App")).toBe( | |
| 116 | "integrate-effect-rpc-into-server-app.md", | |
| 117 | ); | |
| 118 | }); | |
| 119 | ||
| 120 | it("falls back to a generic filename when the plan has no heading", () => { | |
| 121 | expect(buildProposedPlanMarkdownFilename("- step 1")).toBe("plan.md"); | |
| 122 | }); | |
| 123 | }); |
apps/web/src/proposedPlan.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import type { MessageId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import type { ChatMessage } from "./types"; | |
| 4 | ||
| 5 | export interface ProposedPlanBlock { | |
| 6 | beforeText: string; | |
| 7 | planMarkdown: string; | |
| 8 | afterText: string; | |
| 9 | } | |
| 10 | ||
| 11 | export interface ProposedPlanMessageMatch { | |
| 12 | message: ChatMessage; | |
| 13 | plan: ProposedPlanBlock; | |
| 14 | } | |
| 15 | ||
| 16 | const PROPOSED_PLAN_REGEX = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/; | |
| 17 | ||
| 18 | export function parseProposedPlanMessage(text: string): ProposedPlanBlock | null { | |
| 19 | const match = PROPOSED_PLAN_REGEX.exec(text); | |
| 20 | if (!match) { | |
| 21 | return null; | |
| 22 | } | |
| 23 | ||
| 24 | const fullMatch = match[0]; | |
| 25 | const content = match[1]; | |
| 26 | const start = match.index; | |
| 27 | const end = start + fullMatch.length; | |
| 28 | const planMarkdown = content?.trim(); | |
| 29 | if (!planMarkdown) { | |
| 30 | return null; | |
| 31 | } | |
| 32 | ||
| 33 | return { | |
| 34 | beforeText: text.slice(0, start).trim(), | |
| 35 | planMarkdown, | |
| 36 | afterText: text.slice(end).trim(), | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | export function proposedPlanTitle(planMarkdown: string): string | null { | |
| 41 | const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); | |
| 42 | return heading && heading.length > 0 ? heading : null; | |
| 43 | } | |
| 44 | ||
| 45 | function sanitizePlanFileSegment(input: string): string { | |
| 46 | const sanitized = input | |
| 47 | .toLowerCase() | |
| 48 | .replace(/[`'".,!?()[\]{}]+/g, "") | |
| 49 | .replace(/[^a-z0-9]+/g, "-") | |
| 50 | .replace(/^-+|-+$/g, ""); | |
| 51 | return sanitized.length > 0 ? sanitized : "plan"; | |
| 52 | } | |
| 53 | ||
| 54 | export function findLatestProposedPlanMessage( | |
| 55 | messages: readonly ChatMessage[], | |
| 56 | assistantMessageId: MessageId | string | null | undefined, | |
| 57 | ): ProposedPlanMessageMatch | null { | |
| 58 | if (assistantMessageId) { | |
| 59 | const matchingMessage = messages.find( | |
| 60 | (message) => message.id === assistantMessageId && message.role === "assistant", | |
| 61 | ); | |
| 62 | if (matchingMessage) { | |
| 63 | const parsedPlan = parseProposedPlanMessage(matchingMessage.text); | |
| 64 | if (parsedPlan) { | |
| 65 | return { | |
| 66 | message: matchingMessage, | |
| 67 | plan: parsedPlan, | |
| 68 | }; | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | for (let index = messages.length - 1; index >= 0; index -= 1) { | |
| 74 | const message = messages[index]; | |
| 75 | if (!message || message.role !== "assistant") { | |
| 76 | continue; | |
| 77 | } | |
| 78 | const parsedPlan = parseProposedPlanMessage(message.text); | |
| 79 | if (!parsedPlan) { | |
| 80 | continue; | |
| 81 | } | |
| 82 | return { | |
| 83 | message, | |
| 84 | plan: parsedPlan, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | return null; | |
| 89 | } | |
| 90 | ||
| 91 | export function buildPlanImplementationPrompt(planMarkdown: string): string { | |
| 92 | return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; | |
| 93 | } | |
| 94 | ||
| 95 | export function buildPlanImplementationThreadTitle(planMarkdown: string): string { | |
| 96 | const title = proposedPlanTitle(planMarkdown); | |
| 97 | if (!title) { | |
| 98 | return "Implement plan"; | |
| 99 | } | |
| 100 | return `Implement ${title}`; | |
| 101 | } | |
| 102 | ||
| 103 | export function buildProposedPlanMarkdownFilename(planMarkdown: string): string { | |
| 104 | const title = proposedPlanTitle(planMarkdown); | |
| 105 | return `${sanitizePlanFileSegment(title ?? "plan")}.md`; | |
| 106 | } |
apps/web/src/session-logic.test.ts+126| ... | ... | @@ -2,8 +2,10 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | deriveActivePlanState, | |
| 5 | 6 | PROVIDER_OPTIONS, |
| 6 | 7 | derivePendingApprovals, |
| 8 | derivePendingUserInputs, | |
| 7 | 9 | deriveWorkLogEntries, |
| 8 | 10 | hasToolActivityForTurn, |
| 9 | 11 | isLatestTurnSettled, |
| ... | ... | @@ -131,6 +133,130 @@ describe("derivePendingApprovals", () => { |
| 131 | 133 | }); |
| 132 | 134 | }); |
| 133 | 135 | |
| 136 | describe("derivePendingUserInputs", () => { | |
| 137 | it("tracks open structured prompts and removes resolved ones", () => { | |
| 138 | const activities: OrchestrationThreadActivity[] = [ | |
| 139 | makeActivity({ | |
| 140 | id: "user-input-open", | |
| 141 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 142 | kind: "user-input.requested", | |
| 143 | summary: "User input requested", | |
| 144 | tone: "info", | |
| 145 | payload: { | |
| 146 | requestId: "req-user-input-1", | |
| 147 | questions: [ | |
| 148 | { | |
| 149 | id: "sandbox_mode", | |
| 150 | header: "Sandbox", | |
| 151 | question: "Which mode should be used?", | |
| 152 | options: [ | |
| 153 | { | |
| 154 | label: "workspace-write", | |
| 155 | description: "Allow workspace writes only", | |
| 156 | }, | |
| 157 | ], | |
| 158 | }, | |
| 159 | ], | |
| 160 | }, | |
| 161 | }), | |
| 162 | makeActivity({ | |
| 163 | id: "user-input-resolved", | |
| 164 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 165 | kind: "user-input.resolved", | |
| 166 | summary: "User input submitted", | |
| 167 | tone: "info", | |
| 168 | payload: { | |
| 169 | requestId: "req-user-input-2", | |
| 170 | answers: { | |
| 171 | sandbox_mode: "workspace-write", | |
| 172 | }, | |
| 173 | }, | |
| 174 | }), | |
| 175 | makeActivity({ | |
| 176 | id: "user-input-open-2", | |
| 177 | createdAt: "2026-02-23T00:00:01.500Z", | |
| 178 | kind: "user-input.requested", | |
| 179 | summary: "User input requested", | |
| 180 | tone: "info", | |
| 181 | payload: { | |
| 182 | requestId: "req-user-input-2", | |
| 183 | questions: [ | |
| 184 | { | |
| 185 | id: "approval", | |
| 186 | header: "Approval", | |
| 187 | question: "Continue?", | |
| 188 | options: [ | |
| 189 | { | |
| 190 | label: "yes", | |
| 191 | description: "Continue execution", | |
| 192 | }, | |
| 193 | ], | |
| 194 | }, | |
| 195 | ], | |
| 196 | }, | |
| 197 | }), | |
| 198 | ]; | |
| 199 | ||
| 200 | expect(derivePendingUserInputs(activities)).toEqual([ | |
| 201 | { | |
| 202 | requestId: "req-user-input-1", | |
| 203 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 204 | questions: [ | |
| 205 | { | |
| 206 | id: "sandbox_mode", | |
| 207 | header: "Sandbox", | |
| 208 | question: "Which mode should be used?", | |
| 209 | options: [ | |
| 210 | { | |
| 211 | label: "workspace-write", | |
| 212 | description: "Allow workspace writes only", | |
| 213 | }, | |
| 214 | ], | |
| 215 | }, | |
| 216 | ], | |
| 217 | }, | |
| 218 | ]); | |
| 219 | }); | |
| 220 | }); | |
| 221 | ||
| 222 | describe("deriveActivePlanState", () => { | |
| 223 | it("returns the latest plan update for the active turn", () => { | |
| 224 | const activities: OrchestrationThreadActivity[] = [ | |
| 225 | makeActivity({ | |
| 226 | id: "plan-old", | |
| 227 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 228 | kind: "turn.plan.updated", | |
| 229 | summary: "Plan updated", | |
| 230 | tone: "info", | |
| 231 | turnId: "turn-1", | |
| 232 | payload: { | |
| 233 | explanation: "Initial plan", | |
| 234 | plan: [{ step: "Inspect code", status: "pending" }], | |
| 235 | }, | |
| 236 | }), | |
| 237 | makeActivity({ | |
| 238 | id: "plan-latest", | |
| 239 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 240 | kind: "turn.plan.updated", | |
| 241 | summary: "Plan updated", | |
| 242 | tone: "info", | |
| 243 | turnId: "turn-1", | |
| 244 | payload: { | |
| 245 | explanation: "Refined plan", | |
| 246 | plan: [{ step: "Implement Codex user input", status: "inProgress" }], | |
| 247 | }, | |
| 248 | }), | |
| 249 | ]; | |
| 250 | ||
| 251 | expect(deriveActivePlanState(activities, TurnId.makeUnsafe("turn-1"))).toEqual({ | |
| 252 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 253 | turnId: "turn-1", | |
| 254 | explanation: "Refined plan", | |
| 255 | steps: [{ step: "Implement Codex user input", status: "inProgress" }], | |
| 256 | }); | |
| 257 | }); | |
| 258 | }); | |
| 259 | ||
| 134 | 260 | describe("deriveWorkLogEntries", () => { |
| 135 | 261 | it("omits tool started entries and keeps completed entries", () => { |
| 136 | 262 | const activities: OrchestrationThreadActivity[] = [ |
apps/web/src/session-logic.ts+166| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | type OrchestrationLatestTurn, |
| 4 | 4 | type OrchestrationThreadActivity, |
| 5 | 5 | type ProviderKind, |
| 6 | type UserInputQuestion, | |
| 6 | 7 | type TurnId, |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | |
| ... | ... | @@ -33,6 +34,22 @@ export interface PendingApproval { |
| 33 | 34 | detail?: string; |
| 34 | 35 | } |
| 35 | 36 | |
| 37 | export interface PendingUserInput { | |
| 38 | requestId: ApprovalRequestId; | |
| 39 | createdAt: string; | |
| 40 | questions: ReadonlyArray<UserInputQuestion>; | |
| 41 | } | |
| 42 | ||
| 43 | export interface ActivePlanState { | |
| 44 | createdAt: string; | |
| 45 | turnId: TurnId | null; | |
| 46 | explanation?: string | null; | |
| 47 | steps: Array<{ | |
| 48 | step: string; | |
| 49 | status: "pending" | "inProgress" | "completed"; | |
| 50 | }>; | |
| 51 | } | |
| 52 | ||
| 36 | 53 | export type TimelineEntry = |
| 37 | 54 | | { |
| 38 | 55 | id: string; |
| ... | ... | @@ -161,6 +178,155 @@ export function derivePendingApprovals( |
| 161 | 178 | ); |
| 162 | 179 | } |
| 163 | 180 | |
| 181 | function parseUserInputQuestions( | |
| 182 | payload: Record<string, unknown> | null, | |
| 183 | ): ReadonlyArray<UserInputQuestion> | null { | |
| 184 | const questions = payload?.questions; | |
| 185 | if (!Array.isArray(questions)) { | |
| 186 | return null; | |
| 187 | } | |
| 188 | const parsed = questions | |
| 189 | .map<UserInputQuestion | null>((entry) => { | |
| 190 | if (!entry || typeof entry !== "object") return null; | |
| 191 | const question = entry as Record<string, unknown>; | |
| 192 | if ( | |
| 193 | typeof question.id !== "string" || | |
| 194 | typeof question.header !== "string" || | |
| 195 | typeof question.question !== "string" || | |
| 196 | !Array.isArray(question.options) | |
| 197 | ) { | |
| 198 | return null; | |
| 199 | } | |
| 200 | const options = question.options | |
| 201 | .map<UserInputQuestion["options"][number] | null>((option) => { | |
| 202 | if (!option || typeof option !== "object") return null; | |
| 203 | const optionRecord = option as Record<string, unknown>; | |
| 204 | if ( | |
| 205 | typeof optionRecord.label !== "string" || | |
| 206 | typeof optionRecord.description !== "string" | |
| 207 | ) { | |
| 208 | return null; | |
| 209 | } | |
| 210 | return { | |
| 211 | label: optionRecord.label, | |
| 212 | description: optionRecord.description, | |
| 213 | }; | |
| 214 | }) | |
| 215 | .filter((option): option is UserInputQuestion["options"][number] => option !== null); | |
| 216 | if (options.length === 0) { | |
| 217 | return null; | |
| 218 | } | |
| 219 | return { | |
| 220 | id: question.id, | |
| 221 | header: question.header, | |
| 222 | question: question.question, | |
| 223 | options, | |
| 224 | }; | |
| 225 | }) | |
| 226 | .filter((question): question is UserInputQuestion => question !== null); | |
| 227 | return parsed.length > 0 ? parsed : null; | |
| 228 | } | |
| 229 | ||
| 230 | export function derivePendingUserInputs( | |
| 231 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 232 | ): PendingUserInput[] { | |
| 233 | const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>(); | |
| 234 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 235 | ||
| 236 | for (const activity of ordered) { | |
| 237 | const payload = | |
| 238 | activity.payload && typeof activity.payload === "object" | |
| 239 | ? (activity.payload as Record<string, unknown>) | |
| 240 | : null; | |
| 241 | const requestId = | |
| 242 | payload && typeof payload.requestId === "string" | |
| 243 | ? ApprovalRequestId.makeUnsafe(payload.requestId) | |
| 244 | : null; | |
| 245 | ||
| 246 | if (activity.kind === "user-input.requested" && requestId) { | |
| 247 | const questions = parseUserInputQuestions(payload); | |
| 248 | if (!questions) { | |
| 249 | continue; | |
| 250 | } | |
| 251 | openByRequestId.set(requestId, { | |
| 252 | requestId, | |
| 253 | createdAt: activity.createdAt, | |
| 254 | questions, | |
| 255 | }); | |
| 256 | continue; | |
| 257 | } | |
| 258 | ||
| 259 | if (activity.kind === "user-input.resolved" && requestId) { | |
| 260 | openByRequestId.delete(requestId); | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | return [...openByRequestId.values()].toSorted((left, right) => | |
| 265 | left.createdAt.localeCompare(right.createdAt), | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 269 | export function deriveActivePlanState( | |
| 270 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 271 | latestTurnId: TurnId | undefined, | |
| 272 | ): ActivePlanState | null { | |
| 273 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 274 | const candidates = ordered.filter((activity) => { | |
| 275 | if (activity.kind !== "turn.plan.updated") { | |
| 276 | return false; | |
| 277 | } | |
| 278 | if (!latestTurnId) { | |
| 279 | return true; | |
| 280 | } | |
| 281 | return activity.turnId === latestTurnId; | |
| 282 | }); | |
| 283 | const latest = candidates.at(-1); | |
| 284 | if (!latest) { | |
| 285 | return null; | |
| 286 | } | |
| 287 | const payload = | |
| 288 | latest.payload && typeof latest.payload === "object" | |
| 289 | ? (latest.payload as Record<string, unknown>) | |
| 290 | : null; | |
| 291 | const rawPlan = payload?.plan; | |
| 292 | if (!Array.isArray(rawPlan)) { | |
| 293 | return null; | |
| 294 | } | |
| 295 | const steps = rawPlan | |
| 296 | .map((entry) => { | |
| 297 | if (!entry || typeof entry !== "object") return null; | |
| 298 | const record = entry as Record<string, unknown>; | |
| 299 | if (typeof record.step !== "string") { | |
| 300 | return null; | |
| 301 | } | |
| 302 | const status = | |
| 303 | record.status === "completed" || record.status === "inProgress" | |
| 304 | ? record.status | |
| 305 | : "pending"; | |
| 306 | return { | |
| 307 | step: record.step, | |
| 308 | status, | |
| 309 | }; | |
| 310 | }) | |
| 311 | .filter( | |
| 312 | ( | |
| 313 | step, | |
| 314 | ): step is { | |
| 315 | step: string; | |
| 316 | status: "pending" | "inProgress" | "completed"; | |
| 317 | } => step !== null, | |
| 318 | ); | |
| 319 | if (steps.length === 0) { | |
| 320 | return null; | |
| 321 | } | |
| 322 | return { | |
| 323 | createdAt: latest.createdAt, | |
| 324 | turnId: latest.turnId, | |
| 325 | ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), | |
| 326 | steps, | |
| 327 | }; | |
| 328 | } | |
| 329 | ||
| 164 | 330 | export function deriveWorkLogEntries( |
| 165 | 331 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 166 | 332 | latestTurnId: TurnId | undefined, |
apps/web/src/store.test.ts+3-1| ... | ... | @@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; |
| 5 | import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | |
| 5 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | |
| 6 | 6 | |
| 7 | 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 8 | 8 | return { |
| ... | ... | @@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 12 | 12 | title: "Thread", |
| 13 | 13 | model: "gpt-5-codex", |
| 14 | 14 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 15 | interactionMode: DEFAULT_INTERACTION_MODE, | |
| 15 | 16 | session: null, |
| 16 | 17 | messages: [], |
| 17 | 18 | turnDiffSummaries: [], |
| ... | ... | @@ -49,6 +50,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads" |
| 49 | 50 | title: "Thread", |
| 50 | 51 | model: "gpt-5.3-codex", |
| 51 | 52 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 53 | interactionMode: DEFAULT_INTERACTION_MODE, | |
| 52 | 54 | branch: null, |
| 53 | 55 | worktreePath: null, |
| 54 | 56 | latestTurn: null, |
apps/web/src/store.ts+1| ... | ... | @@ -257,6 +257,7 @@ export function syncServerReadModel( |
| 257 | 257 | thread.model, |
| 258 | 258 | ), |
| 259 | 259 | runtimeMode: thread.runtimeMode, |
| 260 | interactionMode: thread.interactionMode, | |
| 260 | 261 | session: thread.session |
| 261 | 262 | ? { |
| 262 | 263 | provider: toLegacyProvider(thread.session.providerName), |
apps/web/src/types.ts+4| ... | ... | @@ -9,11 +9,14 @@ import type { |
| 9 | 9 | MessageId, |
| 10 | 10 | CheckpointRef, |
| 11 | 11 | ProviderKind, |
| 12 | ProviderInteractionMode, | |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | 14 | |
| 14 | 15 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 15 | 16 | export type RuntimeMode = "approval-required" | "full-access"; |
| 16 | 17 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 18 | export type InteractionMode = ProviderInteractionMode; | |
| 19 | export const DEFAULT_INTERACTION_MODE: InteractionMode = "default"; | |
| 17 | 20 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 18 | 21 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; |
| 19 | 22 | export const MAX_THREAD_TERMINAL_COUNT = 4; |
| ... | ... | @@ -78,6 +81,7 @@ export interface Thread { |
| 78 | 81 | title: string; |
| 79 | 82 | model: string; |
| 80 | 83 | runtimeMode: RuntimeMode; |
| 84 | interactionMode: InteractionMode; | |
| 81 | 85 | session: ThreadSession | null; |
| 82 | 86 | messages: ChatMessage[]; |
| 83 | 87 | error: string | null; |
apps/web/src/worktreeCleanup.test.ts+2-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | |
| 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | |
| 5 | 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; |
| 6 | 6 | |
| 7 | 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| ... | ... | @@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 12 | 12 | title: "Thread", |
| 13 | 13 | model: "gpt-5.3-codex", |
| 14 | 14 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 15 | interactionMode: DEFAULT_INTERACTION_MODE, | |
| 15 | 16 | session: null, |
| 16 | 17 | messages: [], |
| 17 | 18 | turnDiffSummaries: [], |
apps/web/src/wsNativeApi.test.ts+19| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ThreadId, |
| 8 | 8 | WS_CHANNELS, |
| 9 | WS_METHODS, | |
| 9 | 10 | type ServerProviderStatus, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| ... | ... | @@ -330,6 +331,24 @@ describe("wsNativeApi", () => { |
| 330 | 331 | }); |
| 331 | 332 | }); |
| 332 | 333 | |
| 334 | it("forwards workspace file writes to the websocket project method", async () => { | |
| 335 | requestMock.mockResolvedValue({ relativePath: "plan.md" }); | |
| 336 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 337 | ||
| 338 | const api = createWsNativeApi(); | |
| 339 | await api.projects.writeFile({ | |
| 340 | cwd: "/tmp/project", | |
| 341 | relativePath: "plan.md", | |
| 342 | contents: "# Plan\n", | |
| 343 | }); | |
| 344 | ||
| 345 | expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, { | |
| 346 | cwd: "/tmp/project", | |
| 347 | relativePath: "plan.md", | |
| 348 | contents: "# Plan\n", | |
| 349 | }); | |
| 350 | }); | |
| 351 | ||
| 333 | 352 | it("forwards full-thread diff requests to the orchestration websocket method", async () => { |
| 334 | 353 | requestMock.mockResolvedValue({ diff: "patch" }); |
| 335 | 354 | const { createWsNativeApi } = await import("./wsNativeApi"); |
apps/web/src/wsNativeApi.ts+1| ... | ... | @@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi { |
| 141 | 141 | }, |
| 142 | 142 | projects: { |
| 143 | 143 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| 144 | writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), | |
| 144 | 145 | }, |
| 145 | 146 | shell: { |
| 146 | 147 | openInEditor: (cwd, editor) => |
docs/plan-mode-deep-dive.md+369-458| ... | ... | @@ -1,653 +1,564 @@ |
| 1 | # Plan Mode Implementation Plan | |
| 1 | # Plan Mode Deep Dive | |
| 2 | 2 | |
| 3 | 3 | ## Goal |
| 4 | 4 | |
| 5 | Implement plan mode in a provider-agnostic way across: | |
| 5 | Implement plan mode without fighting the architecture that already exists in this repo. | |
| 6 | 6 | |
| 7 | - Codex App Server | |
| 8 | - Claude Agent SDK / Claude Code | |
| 9 | - Cursor ACP | |
| 7 | The current app is: | |
| 10 | 8 | |
| 11 | The implementation must support: | |
| 9 | - provider-adapter driven on the server | |
| 10 | - orchestration snapshot driven on the client | |
| 11 | - already equipped with some plan-adjacent runtime events | |
| 12 | - not yet equipped with first-class plan-mode state in the orchestration read model | |
| 12 | 13 | |
| 13 | - provider-native or adapter-derived plan mode state | |
| 14 | - structured user-input question flows where available | |
| 15 | - graceful fallback when a provider lacks structured prompts or structured plan updates | |
| 16 | - a front-end UX that renders from capabilities and canonical events rather than provider-specific conditionals | |
| 14 | This document updates the implementation plan to match that reality. | |
| 17 | 15 | |
| 18 | This document now serves as both: | |
| 16 | --- | |
| 19 | 17 | |
| 20 | - the architecture plan for shared orchestration and UI wiring | |
| 21 | - the provider adapter implementation plan for each supported agent runtime | |
| 18 | ## Current Repo Baseline | |
| 22 | 19 | |
| 23 | --- | |
| 20 | ### 1. Shared contracts already have some plan primitives | |
| 24 | 21 | |
| 25 | ## Source of truth | |
| 22 | `packages/contracts/src/providerRuntime.ts` already includes: | |
| 26 | 23 | |
| 27 | ### Codex | |
| 24 | - `turn.plan.updated` | |
| 25 | - `content.delta` with `streamKind: "plan_text"` | |
| 26 | - `user-input.requested` | |
| 27 | - `user-input.resolved` | |
| 28 | 28 | |
| 29 | Codex protocol decisions should be based on the open-source app-server protocol, not on local rollout session files. | |
| 29 | Important constraint: | |
| 30 | 30 | |
| 31 | Upstream Codex app-server explicitly supports: | |
| 31 | - these are runtime-event primitives only | |
| 32 | - there is no first-class provider capability schema | |
| 33 | - there is no first-class plan operating mode field | |
| 34 | - there is no dedicated final-plan artifact schema | |
| 32 | 35 | |
| 33 | - `turn/plan/updated` | |
| 34 | - `item/plan/delta` | |
| 35 | - `item/tool/requestUserInput` | |
| 36 | - `serverRequest/resolved` | |
| 37 | - lower-level `EventMsg` variants `plan_update`, `plan_delta`, and `request_user_input` | |
| 36 | ### 2. Existing `runtimeMode` is not plan mode | |
| 38 | 37 | |
| 39 | Important implication: | |
| 38 | `RuntimeMode` in `packages/contracts/src/orchestration.ts` and `packages/contracts/src/provider.ts` means execution policy: | |
| 40 | 39 | |
| 41 | - local Codex rollout JSONL files are a lossy or higher-level projection | |
| 42 | - they may preserve plan mode state and final `<proposed_plan>` output | |
| 43 | - they should not be treated as proof that low-level plan events do or do not exist | |
| 40 | - `approval-required` | |
| 41 | - `full-access` | |
| 44 | 42 | |
| 45 | ### Claude | |
| 43 | Plan mode must not overload that field. | |
| 46 | 44 | |
| 47 | Claude protocol decisions should be based on: | |
| 45 | If we need provider operating mode, it needs a separate concept and separate naming, for example: | |
| 48 | 46 | |
| 49 | - installed SDK typings | |
| 50 | - real local session JSONL evidence | |
| 47 | - `interactionMode` | |
| 48 | - `planningMode` | |
| 49 | - `providerOperatingMode` | |
| 51 | 50 | |
| 52 | Confirmed from local Claude transcript: | |
| 51 | Anything is acceptable as long as it is not reused from the existing access-policy `runtimeMode`. | |
| 53 | 52 | |
| 54 | - `AskUserQuestion` is a native `tool_use` | |
| 55 | - `ExitPlanMode` is a native `tool_use` | |
| 56 | - `AskUserQuestion.input.questions[]` is structured and adapter-parseable | |
| 53 | ### 3. Server flow is already canonical-event-first | |
| 57 | 54 | |
| 58 | ### Cursor | |
| 55 | The current server pipeline is: | |
| 59 | 56 | |
| 60 | Cursor protocol decisions should be based on: | |
| 57 | 1. Provider adapters emit `ProviderRuntimeEvent`s. | |
| 58 | 2. `ProviderService` multiplexes adapter streams and maintains provider session bindings. | |
| 59 | 3. `ProviderRuntimeIngestion` translates runtime events into orchestration commands and thread activities. | |
| 60 | 4. `ProjectionPipeline` persists read-model tables. | |
| 61 | 5. `ProjectionSnapshotQuery` rebuilds the orchestration snapshot returned by `orchestration.getSnapshot`. | |
| 62 | 6. `wsServer` exposes generic orchestration snapshot, command dispatch, diff, and replay methods. | |
| 61 | 63 | |
| 62 | - ACP docs | |
| 63 | - current local ACP probe output | |
| 64 | This matters because plan mode should fit into the same pipeline, not bypass it with a parallel websocket protocol. | |
| 64 | 65 | |
| 65 | Confirmed from current docs + probe: | |
| 66 | Important detail: | |
| 66 | 67 | |
| 67 | - ACP session modes include `agent`, `plan`, and `ask` | |
| 68 | - for product semantics, treat ACP mode as binary: `operatingMode: default | plan` | |
| 69 | - Cursor ACP extension methods include: | |
| 70 | - `cursor/ask_question` (multiple-choice question prompt) | |
| 71 | - `cursor/create_plan` (explicit plan approval request) | |
| 72 | - `cursor/update_todos` (todo-state updates that can drive plan-step UI) | |
| 73 | - probe streams still show standard chunk/update events and may not always emit extension methods in every run | |
| 68 | - `wsServer` broadcasts orchestration domain events, not raw provider runtime events | |
| 69 | - if the web should see plan state live, that state must first become orchestration events and/or projections | |
| 74 | 70 | |
| 75 | --- | |
| 71 | ### 4. The current read model has nowhere to put plan mode | |
| 76 | 72 | |
| 77 | ## High-level product model | |
| 73 | `OrchestrationThread` currently contains: | |
| 78 | 74 | |
| 79 | The implementation should separate three concepts that are currently easy to conflate. | |
| 75 | - `messages` | |
| 76 | - `activities` | |
| 77 | - `checkpoints` | |
| 78 | - `session` | |
| 79 | - `latestTurn` | |
| 80 | - thread metadata like title, model, branch, worktree | |
| 80 | 81 | |
| 81 | ### 1. Operating mode | |
| 82 | It does not contain: | |
| 82 | 83 | |
| 83 | This is the agent runtime mode. | |
| 84 | - current structured plan snapshot | |
| 85 | - accumulated plan-text stream | |
| 86 | - pending structured user-input prompt | |
| 87 | - resolved structured answers | |
| 88 | - provider interactive capabilities | |
| 89 | - provider plan/default operating mode | |
| 90 | - final approved plan artifact | |
| 84 | 91 | |
| 85 | Canonical operating mode: | |
| 92 | Today the only flexible place plan data can survive into the web snapshot is `activities[].payload`. | |
| 86 | 93 | |
| 87 | - `default` | |
| 88 | - `plan` | |
| 94 | ### 5. The web app is snapshot-driven, not event-reduced | |
| 89 | 95 | |
| 90 | This should be represented at runtime independently of plan content or prompt cards. | |
| 96 | The client flow today is: | |
| 91 | 97 | |
| 92 | ### 2. Structured plan state | |
| 98 | 1. `EventRouter` listens to `orchestration.domainEvent`. | |
| 99 | 2. On domain events, it re-fetches `orchestration.getSnapshot`. | |
| 100 | 3. Zustand stores the latest normalized snapshot. | |
| 101 | 4. `session-logic.ts` derives UI state like pending approvals and work log from thread activities. | |
| 102 | 5. `ChatView` renders the thread from snapshot state plus those derived selectors. | |
| 93 | 103 | |
| 94 | This is the current structured plan snapshot, if the provider can supply one natively or if the adapter can synthesize one confidently. | |
| 104 | Important implication: | |
| 95 | 105 | |
| 96 | Canonical model: | |
| 106 | - plan mode should be added to the orchestration snapshot and/or activity derivation path | |
| 107 | - it should not depend on a separate client-side event reducer | |
| 97 | 108 | |
| 98 | ```ts | |
| 99 | interface CanonicalPlanState { | |
| 100 | explanation?: string | null; | |
| 101 | steps: Array<{ | |
| 102 | id?: string; | |
| 103 | text: string; | |
| 104 | status: 'pending' | 'inProgress' | 'completed'; | |
| 105 | source: 'native' | 'synthesized'; | |
| 106 | }>; | |
| 107 | } | |
| 108 | ``` | |
| 109 | ### 6. Current implementation status by provider | |
| 109 | 110 | |
| 110 | ### 3. Structured user-input prompt | |
| 111 | #### Codex | |
| 111 | 112 | |
| 112 | This is the product-level question card model. | |
| 113 | Currently implemented: | |
| 113 | 114 | |
| 114 | Canonical model: | |
| 115 | - `turn/plan/updated` maps to `turn.plan.updated` | |
| 116 | - `item/plan/delta` maps to `content.delta` with `streamKind: "plan_text"` | |
| 115 | 117 | |
| 116 | ```ts | |
| 117 | interface CanonicalUserInputPrompt { | |
| 118 | promptId: string; | |
| 119 | title?: string; | |
| 120 | description?: string; | |
| 121 | questions: Array<{ | |
| 122 | id: string; | |
| 123 | header?: string; | |
| 124 | label: string; | |
| 125 | description?: string; | |
| 126 | options: Array<{ | |
| 127 | id: string; | |
| 128 | label: string; | |
| 129 | description?: string; | |
| 130 | recommended?: boolean; | |
| 131 | }>; | |
| 132 | multiSelect?: boolean; | |
| 133 | allowFreeform?: boolean; | |
| 134 | freeformPlaceholder?: string; | |
| 135 | required?: boolean; | |
| 136 | }>; | |
| 137 | source: 'native' | 'tool-derived' | 'synthesized'; | |
| 138 | } | |
| 118 | Current gap: | |
| 139 | 119 | |
| 140 | interface CanonicalUserInputAnswer { | |
| 141 | promptId: string; | |
| 142 | answers: Array<{ | |
| 143 | questionId: string; | |
| 144 | selectedOptionIds?: string[]; | |
| 145 | text?: string; | |
| 146 | }>; | |
| 147 | } | |
| 148 | ``` | |
| 120 | - `item/tool/requestUserInput` is currently classified as `request.opened` with request type `tool_user_input` | |
| 121 | - it is not yet upgraded into `user-input.requested` | |
| 122 | - there is no structured answer submission path yet | |
| 149 | 123 | |
| 150 | This should remain distinct from approvals. | |
| 124 | #### Claude Code | |
| 151 | 125 | |
| 152 | --- | |
| 126 | Currently implemented: | |
| 153 | 127 | |
| 154 | ## Shared orchestration plan | |
| 128 | - session/config metadata surfaces through `session.configured` | |
| 129 | - assistant/reasoning text flows through normal runtime content events | |
| 130 | - approval requests already bridge into `request.opened` / `request.resolved` | |
| 155 | 131 | |
| 156 | ## Summary | |
| 132 | Current gap: | |
| 157 | 133 | |
| 158 | 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. | |
| 134 | - no current canonical plan-mode mapping | |
| 135 | - no current `AskUserQuestion` mapping | |
| 136 | - no current `ExitPlanMode` mapping | |
| 159 | 137 | |
| 160 | ### Shared goals | |
| 138 | #### Cursor | |
| 161 | 139 | |
| 162 | - normalize plan mode state from each provider | |
| 163 | - normalize structured prompts where possible | |
| 164 | - preserve native payloads for later refinement | |
| 165 | - avoid UI coupling to provider names | |
| 166 | - support degraded conversational fallback where native structured prompts are unavailable | |
| 140 | Currently implemented: | |
| 141 | ||
| 142 | - `available_commands_update` maps to `session.configured` | |
| 143 | - thought/message chunks map to `content.delta` | |
| 144 | - permission requests bridge into approval request events | |
| 145 | ||
| 146 | Current gap: | |
| 147 | ||
| 148 | - no current plan-mode mapping | |
| 149 | - no current structured prompt mapping | |
| 150 | - no current todo/plan projection | |
| 167 | 151 | |
| 168 | 152 | --- |
| 169 | 153 | |
| 170 | ## Orchestration work items | |
| 154 | ## Design Constraints | |
| 171 | 155 | |
| 172 | ### 1. Extend canonical provider runtime contracts | |
| 156 | ### Keep the current transport model | |
| 173 | 157 | |
| 174 | Files: | |
| 158 | Plan mode should continue to flow through: | |
| 175 | 159 | |
| 176 | - `packages/contracts/src/providerRuntime.ts` | |
| 177 | - `packages/contracts/src/provider.ts` | |
| 178 | - `EVENTS.md` | |
| 160 | - `ProviderRuntimeEvent` | |
| 161 | - orchestration commands/events | |
| 162 | - projection tables | |
| 163 | - `orchestration.getSnapshot` | |
| 164 | - `orchestration.domainEvent` | |
| 179 | 165 | |
| 180 | Add or formalize the following runtime concepts: | |
| 166 | Do not introduce provider-specific websocket channels unless the current architecture proves insufficient. | |
| 181 | 167 | |
| 182 | - provider interactive capability payload | |
| 183 | - plan-mode state event or mode metadata surface | |
| 184 | - canonical structured user-input payload schema | |
| 185 | - canonical structured user-input answer payload schema | |
| 186 | - final-plan handoff payload for providers that emit a completed plan as text or tool output | |
| 168 | ### Keep the client snapshot-driven | |
| 187 | 169 | |
| 188 | Required additions: | |
| 170 | The client already assumes that server state is re-synced from snapshots. Plan mode should respect that. | |
| 189 | 171 | |
| 190 | ```ts | |
| 191 | interface ProviderInteractiveCapabilities { | |
| 192 | supportsPlanMode: boolean; | |
| 193 | supportsStructuredPlanUpdates: boolean; | |
| 194 | supportsPlanTextStreaming: boolean; | |
| 195 | supportsStructuredUserInput: boolean; | |
| 196 | supportsFreeformUserInput: boolean; | |
| 197 | supportsExitPlanMode: boolean; | |
| 198 | } | |
| 199 | ``` | |
| 172 | That means: | |
| 200 | 173 | |
| 201 | Canonical runtime behavior: | |
| 174 | - server-side persistence matters more than clever client buffering | |
| 175 | - reconnect behavior should come from projection state, not ad hoc React state | |
| 176 | - `session-logic.ts` and `ChatView` are the main frontend extension seams | |
| 202 | 177 | |
| 203 | - `request.opened/request.resolved` remain transport-oriented | |
| 204 | - `user-input.requested/user-input.resolved` remain product-oriented | |
| 205 | - `turn.plan.updated` remains the canonical structured plan update event | |
| 206 | - `content.delta(streamKind=plan_text)` remains the canonical streaming plan text surface | |
| 178 | ### Keep approvals separate from structured user input | |
| 207 | 179 | |
| 208 | ### 2. Add canonical mode/capability publication from providers | |
| 180 | The current system has a full approval pipeline: | |
| 209 | 181 | |
| 210 | Files: | |
| 182 | - runtime `request.opened` / `request.resolved` | |
| 183 | - orchestration `thread.approval.respond` | |
| 184 | - provider `respondToRequest(...)` | |
| 211 | 185 | |
| 212 | - `apps/server/src/provider/Layers/*Adapter.ts` | |
| 213 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | |
| 186 | Plan-mode structured prompts are different and should not be forced into the approval model unless a provider genuinely models them as approval requests. | |
| 214 | 187 | |
| 215 | Each adapter should publish provider capabilities and current operating mode early in session startup and when mode changes. | |
| 188 | ### Preserve raw provider payloads | |
| 216 | 189 | |
| 217 | Preferred shape: | |
| 190 | When adding plan-mode mappings: | |
| 218 | 191 | |
| 219 | - capabilities included in `session.configured` | |
| 220 | - current mode included in `session.configured`, `session.state.changed`, or a dedicated mode field on turn/session runtime metadata | |
| 192 | - keep `raw` on internal canonical runtime events | |
| 193 | - keep `providerRefs` on internal canonical runtime events | |
| 194 | - avoid throwing away native payload shape too early inside adapter/server processing | |
| 221 | 195 | |
| 222 | ### 3. Preserve and project plan/user-input state in orchestration | |
| 196 | This does not mean provider-specific payloads should leak into the orchestration read model, websocket API, or web UI. | |
| 223 | 197 | |
| 224 | Files: | |
| 198 | The boundary should stay: | |
| 225 | 199 | |
| 226 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | |
| 227 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts` | |
| 228 | - any orchestration projection models feeding the web socket domain events | |
| 200 | - adapter/server internals may retain native payloads for correlation, debugging, and future parser improvements | |
| 201 | - shared orchestration state and client-visible contracts should expose only canonical plan-mode data | |
| 229 | 202 | |
| 230 | Implementation requirements: | |
| 203 | That matters most for Claude and Cursor, where some mappings will be adapter-derived and may need native context during implementation and debugging. | |
| 231 | 204 | |
| 232 | - persist latest structured plan snapshot per active turn | |
| 233 | - persist pending structured user-input prompts per turn | |
| 234 | - persist resolved answers for history rendering | |
| 235 | - preserve raw/native payloads for debugging and future adapter improvements | |
| 205 | --- | |
| 236 | 206 | |
| 237 | ### 4. Add frontend state model for plan mode | |
| 207 | ## Updated Shared Work Plan | |
| 238 | 208 | |
| 239 | Files likely involved: | |
| 209 | ## 1. Tighten shared contract naming before wiring features | |
| 240 | 210 | |
| 241 | - web app session/thread stores | |
| 242 | - conversation/event rendering layer | |
| 243 | - input composer / pending interaction surfaces | |
| 211 | The first step is not to invent a new architecture. It is to formalize the concepts that are currently missing from contracts. | |
| 244 | 212 | |
| 245 | Add frontend state for: | |
| 213 | ### Add a plan operating mode concept | |
| 246 | 214 | |
| 247 | - current provider capabilities | |
| 248 | - current operating mode | |
| 249 | - pending structured prompt | |
| 250 | - structured plan snapshot | |
| 251 | - streaming plan text buffer | |
| 252 | - final plan handoff state when present | |
| 215 | Add a new shared contract for provider plan/default mode. | |
| 253 | 216 | |
| 254 | ### 5. Implement capability-driven UI rendering | |
| 217 | Requirements: | |
| 255 | 218 | |
| 256 | UI rules: | |
| 219 | - do not reuse `RuntimeMode` | |
| 220 | - keep it orthogonal to access policy | |
| 221 | - make it available in the server->web read model, not only in raw runtime payloads | |
| 257 | 222 | |
| 258 | - if `supportsStructuredUserInput`, render the multi-question card UI | |
| 259 | - if not, fall back to standard conversational reply flow | |
| 260 | - if `supportsStructuredPlanUpdates`, render status-tracked plan steps | |
| 261 | - if only `supportsPlanTextStreaming`, render streaming plan prose | |
| 262 | - if neither exists, render standard assistant content in plan mode with clear mode indicator | |
| 223 | Preferred shape: | |
| 263 | 224 | |
| 264 | ### 6. Implement front-to-back response submission path | |
| 225 | ```ts | |
| 226 | type ProviderInteractionMode = "default" | "plan"; | |
| 227 | ``` | |
| 265 | 228 | |
| 266 | Requirements: | |
| 229 | ### Add typed provider interactive capabilities | |
| 267 | 230 | |
| 268 | - question card submit sends canonical answer payload to server | |
| 269 | - server routes answer to the correct provider adapter pending request/tool context | |
| 270 | - resolved provider response emits both: | |
| 271 | - `user-input.resolved` | |
| 272 | - `request.resolved` when applicable | |
| 231 | Current `session.configured.payload.config` is just `Record<string, unknown>`. That is too weak for capability-driven UI. | |
| 273 | 232 | |
| 274 | ### 7. History and reconnect behavior | |
| 233 | Add a typed capability surface in `packages/contracts` that both server and web can import directly. | |
| 275 | 234 | |
| 276 | Requirements: | |
| 235 | Preferred shape: | |
| 277 | 236 | |
| 278 | - pending structured prompts should survive reconnect/resubscribe if still active | |
| 279 | - resolved prompts should be rendered in history as completed interactions | |
| 280 | - plan snapshots should be replayable from persisted orchestration activity | |
| 281 | - current streaming plan text should resume cleanly on reconnect if provider continues emitting deltas | |
| 237 | - exported constant/object keyed by `ProviderKind` | |
| 238 | - exported type derived from that object | |
| 239 | - usable by adapters, orchestration code, and frontend rendering logic without duplicating provider capability tables | |
| 282 | 240 | |
| 283 | --- | |
| 241 | Minimum fields: | |
| 284 | 242 | |
| 285 | ## Provider adapter plans | |
| 243 | ```ts | |
| 244 | interface ProviderInteractiveCapabilities { | |
| 245 | supportsPlanMode: boolean; | |
| 246 | supportsStructuredPlanUpdates: boolean; | |
| 247 | supportsPlanTextStreaming: boolean; | |
| 248 | supportsStructuredUserInput: boolean; | |
| 249 | supportsFreeformUserInput: boolean; | |
| 250 | supportsPlanAcceptance: boolean; | |
| 251 | } | |
| 252 | ``` | |
| 286 | 253 | |
| 287 | ## Provider 1: Codex App Server | |
| 254 | Preferred usage: | |
| 288 | 255 | |
| 289 | ### Summary | |
| 256 | - static provider capabilities live in shared contracts | |
| 257 | - adapters may still emit dynamic runtime metadata when a capability is session-specific or probe-dependent | |
| 258 | - the orchestration snapshot should expose the active provider and any dynamic overrides, not duplicate the full static capability catalog per thread | |
| 290 | 259 | |
| 291 | Codex is the reference implementation and should be wired as the strongest native provider. | |
| 260 | ### Add a dedicated structured-answer command path | |
| 292 | 261 | |
| 293 | ### Native protocol mapping | |
| 262 | Current shared commands only support approvals: | |
| 294 | 263 | |
| 295 | Source of truth: upstream open-source app-server protocol. | |
| 264 | - `thread.approval.respond` | |
| 265 | - provider `respondToRequest(...)` | |
| 296 | 266 | |
| 297 | Native incoming surfaces: | |
| 267 | Plan mode needs a distinct path for structured prompt answers. | |
| 298 | 268 | |
| 299 | - `turn/plan/updated` | |
| 300 | - `item/plan/delta` | |
| 301 | - `item/tool/requestUserInput` | |
| 302 | - `serverRequest/resolved` | |
| 303 | - lower-level `EventMsg.plan_update` | |
| 304 | - lower-level `EventMsg.plan_delta` | |
| 305 | - lower-level `EventMsg.request_user_input` | |
| 269 | Preferred additions: | |
| 306 | 270 | |
| 307 | ### Adapter implementation | |
| 271 | - orchestration command such as `thread.user-input.respond` | |
| 272 | - provider service method such as `respondToUserInput(...)` | |
| 273 | - adapter hook per provider for native answer serialization | |
| 308 | 274 | |
| 309 | Files: | |
| 275 | Do not overload approval decisions like `accept` / `decline` for structured question answers. | |
| 310 | 276 | |
| 311 | - `apps/server/src/provider/Layers/CodexAdapter.ts` | |
| 312 | - related tests in `apps/server/src/provider/Layers/*Codex*.test.ts` | |
| 277 | ## 2. Extend the orchestration read model | |
| 313 | 278 | |
| 314 | Implementation requirements: | |
| 279 | The current read model is the main missing piece. | |
| 315 | 280 | |
| 316 | 1. Keep native structured plan updates as-is: | |
| 317 | - `turn/plan/updated` -> `turn.plan.updated` | |
| 281 | Plan mode needs first-class projection state so reconnect and refresh behave like the rest of the app. | |
| 318 | 282 | |
| 319 | 2. Keep native plan text streaming as-is: | |
| 320 | - `item/plan/delta` -> `content.delta` with `streamKind: "plan_text"` | |
| 283 | ### Add first-class thread plan state | |
| 321 | 284 | |
| 322 | 3. Keep native structured user-input as-is: | |
| 323 | - `item/tool/requestUserInput` -> `user-input.requested` | |
| 324 | - also emit `request.opened` with a transport request type | |
| 285 | Preferred thread-level additions: | |
| 325 | 286 | |
| 326 | 4. On client answer submission: | |
| 327 | - route answer to the corresponding Codex pending request id | |
| 328 | - emit `user-input.resolved` | |
| 329 | - observe/forward `serverRequest/resolved` -> `request.resolved` | |
| 287 | - `interactionMode` | |
| 288 | - `currentPlan` | |
| 289 | - `pendingUserInput` | |
| 290 | - `resolvedUserInputs` | |
| 291 | - optional `finalPlanArtifact` | |
| 330 | 292 | |
| 331 | 5. Publish capabilities: | |
| 332 | - `supportsPlanMode = true` | |
| 333 | - `supportsStructuredPlanUpdates = true` | |
| 334 | - `supportsPlanTextStreaming = true` | |
| 335 | - `supportsStructuredUserInput = true` | |
| 336 | - `supportsFreeformUserInput = true` if answer payload supports note/text in practice | |
| 337 | - `supportsExitPlanMode = false` unless a separate explicit tool/event is introduced | |
| 293 | The exact nesting can vary, but it should be snapshot-friendly and not require replaying arbitrary activity payloads in the client. | |
| 338 | 294 | |
| 339 | ### Codex-specific tests | |
| 295 | `interactiveCapabilities` should not be modeled as thread-owned state if they are static per provider. | |
| 340 | 296 | |
| 341 | - `turn/plan/updated` maps to canonical structured plan state | |
| 342 | - `item/plan/delta` maps to `plan_text` | |
| 343 | - `item/tool/requestUserInput` maps to canonical structured prompt shape | |
| 344 | - answer response resolves pending request and emits both canonical resolved events | |
| 345 | - reconnect/history replay preserves plan snapshot and pending prompt | |
| 297 | Preferred split: | |
| 346 | 298 | |
| 347 | --- | |
| 299 | - static capabilities: shared contract export keyed by provider | |
| 300 | - dynamic per-thread/per-session state: only fields that can actually vary at runtime, such as current interaction mode or active pending prompt | |
| 348 | 301 | |
| 349 | ## Provider 2: Claude Agent SDK / Claude Code | |
| 302 | ### Keep activities as secondary audit trail | |
| 350 | 303 | |
| 351 | ### Summary | |
| 304 | `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. | |
| 352 | 305 | |
| 353 | 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. | |
| 306 | ### Projection changes | |
| 354 | 307 | |
| 355 | ### Real native evidence | |
| 308 | This likely means: | |
| 356 | 309 | |
| 357 | From the local Claude session transcript: | |
| 310 | - new projection repository/table(s) for plan state and prompt state | |
| 311 | - `ProjectionPipeline` updates | |
| 312 | - `ProjectionSnapshotQuery` returning the new fields | |
| 313 | - projection tests for persistence and replay | |
| 358 | 314 | |
| 359 | - `AskUserQuestion` arrives as: | |
| 360 | - assistant message | |
| 361 | - content block type `tool_use` | |
| 362 | - `name: "AskUserQuestion"` | |
| 363 | - `input.questions[]` | |
| 364 | - `ExitPlanMode` arrives as: | |
| 365 | - assistant message | |
| 366 | - content block type `tool_use` | |
| 367 | - `name: "ExitPlanMode"` | |
| 368 | - `input.plan` containing finalized plan text/spec | |
| 315 | ## 3. Update runtime ingestion to populate that state | |
| 369 | 316 | |
| 370 | Claude also has native: | |
| 317 | `ProviderRuntimeIngestion` already knows how to: | |
| 371 | 318 | |
| 372 | - `permissionMode: 'plan'` | |
| 373 | - `system:init.permissionMode` | |
| 374 | - `system:status.permissionMode` | |
| 319 | - turn assistant text into messages | |
| 320 | - turn approval events into activities | |
| 321 | - turn `turn.plan.updated` into an activity | |
| 375 | 322 | |
| 376 | ### Adapter implementation | |
| 323 | It should be extended to also translate plan/user-input runtime events into read-model state. | |
| 377 | 324 | |
| 378 | Files: | |
| 325 | Implementation direction: | |
| 379 | 326 | |
| 380 | - `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` | |
| 381 | - `apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts` | |
| 327 | - `turn.plan.updated` updates projected plan snapshot | |
| 328 | - `content.delta(plan_text)` appends to projected plan-text buffer when relevant | |
| 329 | - `user-input.requested` opens a projected pending prompt | |
| 330 | - `user-input.resolved` closes/resolves that prompt | |
| 331 | - providers that only expose raw/native blobs can still emit synthesized canonical runtime events first, then rely on the shared ingestion path | |
| 382 | 332 | |
| 383 | Implementation requirements: | |
| 333 | ## 4. Integrate plan mode into the existing web seams | |
| 384 | 334 | |
| 385 | 1. Publish native operating mode: | |
| 386 | - `permissionMode: 'plan'` -> canonical mode `plan` | |
| 387 | - all other values -> canonical mode `default` | |
| 335 | The web architecture already has the right places. | |
| 388 | 336 | |
| 389 | 2. Detect `AskUserQuestion` tool uses: | |
| 390 | - inspect `tool_use` blocks in assistant messages / stream events | |
| 391 | - when `name === 'AskUserQuestion'`, convert `input.questions[]` to canonical `user-input.requested` | |
| 392 | - also emit `request.opened` using a Claude-specific tool-user-input request type | |
| 337 | ### Store and types | |
| 393 | 338 | |
| 394 | 3. Canonical question mapping for Claude: | |
| 395 | - `header` -> canonical `header` | |
| 396 | - `question` -> canonical `label` | |
| 397 | - `options[].label` -> canonical option label | |
| 398 | - `options[].description` -> canonical option description | |
| 399 | - `multiSelect` -> canonical `multiSelect` | |
| 400 | - source = `tool-derived` | |
| 339 | Update: | |
| 401 | 340 | |
| 402 | 4. Implement answer submission path: | |
| 403 | - on UI answer submit, convert canonical answers back into whatever Claude expects for the `AskUserQuestion` tool result path | |
| 404 | - emit `user-input.resolved` | |
| 405 | - emit `request.resolved` | |
| 341 | - `apps/web/src/types.ts` | |
| 342 | - `apps/web/src/store.ts` | |
| 406 | 343 | |
| 407 | 5. Detect `ExitPlanMode` tool uses: | |
| 408 | - when `name === 'ExitPlanMode'`, parse `input.plan` | |
| 409 | - emit a final plan handoff event and/or synthesize a `turn.plan.updated` snapshot if safe | |
| 410 | - treat this as the boundary between planning and implementation readiness | |
| 344 | The store should stay thin and snapshot-oriented. It should sync whatever new read-model fields the server exposes. | |
| 411 | 345 | |
| 412 | 6. Structured plan behavior: | |
| 413 | - Claude does not currently appear to expose a native `turn/plan/updated` equivalent | |
| 414 | - initial implementation should not over-synthesize incremental steps | |
| 415 | - use two-tier strategy: | |
| 416 | - tier 1: preserve plan-related assistant text in `plan_text` only if clearly attributable | |
| 417 | - tier 2: optionally synthesize `turn.plan.updated` from explicit structured plan strings only when parsing is robust | |
| 346 | ### Derived UI state | |
| 418 | 347 | |
| 419 | 7. Publish capabilities: | |
| 420 | - `supportsPlanMode = true` | |
| 421 | - `supportsStructuredPlanUpdates = partial/false initially` | |
| 422 | - `supportsPlanTextStreaming = partial` | |
| 423 | - `supportsStructuredUserInput = true` | |
| 424 | - `supportsFreeformUserInput = unknown/false until answer path confirmed` | |
| 425 | - `supportsExitPlanMode = true` | |
| 348 | Update: | |
| 426 | 349 | |
| 427 | ### Claude-specific tests | |
| 350 | - `apps/web/src/session-logic.ts` | |
| 428 | 351 | |
| 429 | - `system:init` and `system:status` publish plan mode metadata | |
| 430 | - `AskUserQuestion` tool maps into canonical prompt schema | |
| 431 | - multi-select and option descriptions round-trip correctly | |
| 432 | - answer submission resolves the pending prompt | |
| 433 | - `ExitPlanMode` tool emits final plan handoff state | |
| 434 | - ordinary tool uses do not get misclassified as prompts or plan exits | |
| 352 | This is the natural place for: | |
| 435 | 353 | |
| 436 | ### Claude-specific open question to resolve during implementation | |
| 354 | - `derivePlanState(...)` | |
| 355 | - `derivePendingUserInput(...)` | |
| 356 | - any fallback derivation from activities during an incremental migration | |
| 437 | 357 | |
| 438 | - exact runtime shape expected for the answer payload returned to `AskUserQuestion` | |
| 358 | ### Rendering | |
| 439 | 359 | |
| 440 | Implementation default: | |
| 360 | Primary UI seam: | |
| 441 | 361 | |
| 442 | - build adapter abstraction so only the final answer-serialization function is provider-specific and easily swappable once runtime shape is confirmed | |
| 362 | - `apps/web/src/components/ChatView.tsx` | |
| 443 | 363 | |
| 444 | --- | |
| 364 | Current UI already has: | |
| 445 | 365 | |
| 446 | ## Provider 3: Cursor ACP | |
| 366 | - top-of-thread alert stacks for pending approvals | |
| 367 | - work-log rendering derived from activities | |
| 368 | - timeline rows for messages and work state | |
| 447 | 369 | |
| 448 | ### Summary | |
| 370 | That suggests two viable rendering patterns: | |
| 449 | 371 | |
| 450 | 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. | |
| 372 | 1. active plan/prompt panel above the timeline | |
| 373 | 2. resolved plan/prompt entries rendered in timeline/history | |
| 451 | 374 | |
| 452 | ### Real native evidence | |
| 375 | The route structure does not need to change. Plan mode should remain thread-local state inside the existing chat route. | |
| 453 | 376 | |
| 454 | From the Cursor ACP docs + current probe: | |
| 377 | --- | |
| 455 | 378 | |
| 456 | - available modes include `agent`, `plan`, and `ask` | |
| 457 | - Cursor ACP docs define extension methods: | |
| 458 | - `cursor/ask_question` | |
| 459 | - `cursor/create_plan` | |
| 460 | - `cursor/update_todos` | |
| 461 | - observed updates include: | |
| 462 | - `available_commands_update` | |
| 463 | - `agent_thought_chunk` | |
| 464 | - `agent_message_chunk` | |
| 465 | - `tool_call` | |
| 466 | - `tool_call_update` | |
| 467 | - observed request type: | |
| 468 | - `session/request_permission` | |
| 379 | ## Provider Implementation Plan | |
| 469 | 380 | |
| 470 | Current caveats: | |
| 381 | ## Provider 1: Codex | |
| 471 | 382 | |
| 472 | - no standard ACP event equivalent to Codex `turn/plan/updated` | |
| 473 | - no standard ACP event equivalent to Codex `item/plan/delta` | |
| 474 | - extension-method payload shapes should be fixture-captured from live sessions before locking parser assumptions | |
| 383 | Codex is closest to the target shape and should be the first end-to-end implementation. | |
| 475 | 384 | |
| 476 | ### Adapter implementation | |
| 385 | ### Current status | |
| 477 | 386 | |
| 478 | Files: | |
| 387 | Already present: | |
| 479 | 388 | |
| 480 | - `apps/server/src/provider/Layers/CursorAdapter.ts` | |
| 481 | - `apps/server/src/provider/Layers/CursorAdapter.test.ts` | |
| 482 | - probe scripts under `scripts/` | |
| 389 | - native structured plan update mapping | |
| 390 | - native plan-text stream mapping | |
| 483 | 391 | |
| 484 | Implementation requirements: | |
| 392 | Missing: | |
| 485 | 393 | |
| 486 | 1. Publish native operating mode: | |
| 487 | - map ACP `plan` mode -> canonical `plan` | |
| 488 | - map ACP `agent` and `ask` modes -> canonical `default` | |
| 489 | - preserve raw ACP mode in native metadata for debugging | |
| 394 | - native structured prompt mapping for `item/tool/requestUserInput` | |
| 395 | - structured answer submission path | |
| 396 | - removal of the current empty-answer auto-ack path in `codexAppServerManager` | |
| 397 | - projection and UI support for the already-emitted plan events | |
| 490 | 398 | |
| 491 | 2. Implement documented Cursor extension methods: | |
| 492 | - `cursor/ask_question` -> canonical `user-input.requested` | |
| 493 | - `cursor/update_todos` -> canonical `turn.plan.updated` when payload is parseable into stable step ids/text/status | |
| 494 | - `cursor/create_plan` -> canonical final-plan handoff / approval-request surface | |
| 399 | ### Required work | |
| 495 | 400 | |
| 496 | 3. Use graceful fallback behavior: | |
| 497 | - render plan mode as mode state + assistant text stream | |
| 498 | - if no `cursor/ask_question` event is emitted, fall back to normal conversational input | |
| 499 | - if no parseable `cursor/update_todos` event is emitted, fall back to text-only plan rendering | |
| 401 | 1. Upgrade `item/tool/requestUserInput` from request-only handling to canonical `user-input.requested`. | |
| 402 | 2. Preserve `request.opened` as transport/debug metadata when useful. | |
| 403 | 3. Add adapter-side answer submission for the corresponding pending request/tool context. | |
| 404 | 4. Emit `user-input.resolved` when the answer is submitted or confirmed resolved. | |
| 405 | 5. Project the result into the orchestration snapshot. | |
| 500 | 406 | |
| 501 | 4. Preserve room for future enrichment: | |
| 502 | - keep raw ACP notifications available in native event logs | |
| 503 | - extend the ACP probe to search for hidden or uncommon prompt/mode-change surfaces | |
| 407 | ### Codex-specific note | |
| 504 | 408 | |
| 505 | 5. Publish capabilities: | |
| 506 | - `supportsPlanMode = true` | |
| 507 | - `supportsStructuredPlanUpdates = true` when `cursor/update_todos` is available, otherwise false | |
| 508 | - `supportsPlanTextStreaming = false` initially (no distinct plan-text channel) | |
| 509 | - `supportsStructuredUserInput = true` when `cursor/ask_question` is available, otherwise false | |
| 510 | - `supportsFreeformUserInput = false` via structured prompt path | |
| 511 | - `supportsExitPlanMode = false` | |
| 409 | The runtime event schemas already match Codex reasonably well, so this provider should define the canonical end-to-end behavior first. | |
| 512 | 410 | |
| 513 | ### Cursor-specific tests | |
| 411 | ## Provider 2: Claude Code | |
| 514 | 412 | |
| 515 | - ACP mode metadata maps to `plan` vs `default` correctly | |
| 516 | - `cursor/ask_question` maps into canonical structured prompt schema | |
| 517 | - `cursor/update_todos` maps into canonical structured plan state when parseable | |
| 518 | - `cursor/create_plan` maps into final-plan handoff / approval surface | |
| 519 | - `agent_thought_chunk` and `agent_message_chunk` still render normally in plan mode | |
| 520 | - fallback conversational flow remains functional | |
| 413 | Claude likely needs tool-aware adaptation rather than pure transport mapping. | |
| 521 | 414 | |
| 522 | ### Cursor-specific follow-up probe work | |
| 415 | ### Current status | |
| 523 | 416 | |
| 524 | Add dedicated probes for: | |
| 417 | Already present: | |
| 525 | 418 | |
| 526 | - mode-switching during an active session | |
| 527 | - real payload shapes for `cursor/ask_question`, `cursor/create_plan`, and `cursor/update_todos` | |
| 528 | - any request types besides `session/request_permission` | |
| 529 | - whether extension methods are always emitted or only for specific prompt styles | |
| 419 | - generic session/runtime stream integration | |
| 420 | - approval bridging | |
| 530 | 421 | |
| 531 | --- | |
| 422 | Missing: | |
| 532 | 423 | |
| 533 | ## Frontend implementation plan | |
| 424 | - plan/default operating mode mapping | |
| 425 | - `AskUserQuestion` -> `user-input.requested` | |
| 426 | - `ExitPlanMode` -> final-plan or plan-state handoff | |
| 534 | 427 | |
| 535 | ### Summary | |
| 428 | ### Required work | |
| 536 | 429 | |
| 537 | Render plan mode through a single UI model driven by canonical events and provider capabilities. | |
| 430 | 1. Detect plan/default operating mode from Claude-native metadata without touching access-policy `runtimeMode`. | |
| 431 | 2. Map `AskUserQuestion` tool payloads into canonical `user-input.requested`. | |
| 432 | 3. Add provider-specific answer serialization for the return path. | |
| 433 | 4. Detect `ExitPlanMode` and decide whether it becomes: | |
| 434 | - a final-plan artifact | |
| 435 | - a synthetic structured plan snapshot | |
| 436 | - or both | |
| 437 | 5. Avoid over-synthesizing incremental structured plan steps unless Claude gives us enough structure to do it safely. | |
| 538 | 438 | |
| 539 | ### UI states to support | |
| 439 | ## Provider 3: Cursor | |
| 540 | 440 | |
| 541 | - standard conversation | |
| 542 | - plan mode with structured steps | |
| 543 | - plan mode with text-only plan stream | |
| 544 | - pending structured question card | |
| 545 | - resolved question card in history | |
| 546 | - final plan handoff / completed plan artifact | |
| 441 | Cursor should start from the same canonical server pipeline, but with a more conservative adapter. | |
| 547 | 442 | |
| 548 | ### Rendering rules | |
| 443 | ### Current status | |
| 549 | 444 | |
| 550 | 1. Show operating mode indicator whenever current mode is `plan` | |
| 551 | 2. Show structured question card only when a pending `user-input.requested` exists | |
| 552 | 3. Show structured plan step list when a current `turn.plan.updated` snapshot exists | |
| 553 | 4. Append `plan_text` streaming content beneath or alongside structured steps when both exist | |
| 554 | 5. Fall back to assistant text rendering when provider capabilities do not support structure | |
| 555 | 6. Show completed selected answers in history after `user-input.resolved` | |
| 445 | Already present: | |
| 556 | 446 | |
| 557 | ### Submission behavior | |
| 447 | - session/update stream handling | |
| 448 | - reasoning/assistant text mapping | |
| 449 | - approval bridging | |
| 558 | 450 | |
| 559 | - option click / freeform answer submits canonical answer payload | |
| 560 | - disable duplicate submits while request is pending resolution | |
| 561 | - preserve pending-card state across reconnects if request remains open | |
| 451 | Missing: | |
| 562 | 452 | |
| 563 | --- | |
| 453 | - plan/default operating mode mapping | |
| 454 | - structured prompt mapping | |
| 455 | - structured plan/todo mapping | |
| 564 | 456 | |
| 565 | ## Data flow end to end | |
| 457 | ### Required work | |
| 566 | 458 | |
| 567 | 1. Provider starts session and publishes capabilities + mode | |
| 568 | 2. User starts plan-mode turn or provider enters plan mode | |
| 569 | 3. Adapter emits structured plan and/or plan text events when available | |
| 570 | 4. Adapter emits `user-input.requested` when provider asks a structured question | |
| 571 | 5. Frontend renders question card or fallback conversational prompt based on capabilities | |
| 572 | 6. User submits answer | |
| 573 | 7. Server routes answer back to provider adapter pending request/tool context | |
| 574 | 8. Adapter emits `user-input.resolved` and `request.resolved` | |
| 575 | 9. Provider may continue planning, emit more plan updates, or emit final plan handoff | |
| 576 | 10. Frontend renders final completed plan state and history | |
| 459 | 1. Detect and publish Cursor operating mode separately from access-policy runtime mode. | |
| 460 | 2. Map documented extension methods such as `cursor/ask_question` and `cursor/update_todos` only when payloads are confirmed. | |
| 461 | 3. Fall back to plan-mode indicator plus normal assistant/reasoning text if structured Cursor plan events are absent. | |
| 462 | 4. Preserve raw ACP payloads so the adapter can improve without changing higher layers. | |
| 577 | 463 | |
| 578 | 464 | --- |
| 579 | 465 | |
| 580 | ## Test plan | |
| 466 | ## Frontend Rendering Plan | |
| 581 | 467 | |
| 582 | ### Contracts | |
| 468 | The frontend should remain capability-driven, but using real repo seams. | |
| 583 | 469 | |
| 584 | Files: | |
| 470 | ### Active state | |
| 585 | 471 | |
| 586 | - `packages/contracts/src/providerRuntime.test.ts` | |
| 587 | - related schema tests | |
| 472 | Render from projected snapshot fields: | |
| 588 | 473 | |
| 589 | Add coverage for: | |
| 474 | - current plan/default operating mode | |
| 475 | - provider capabilities | |
| 476 | - current structured plan | |
| 477 | - current plan-text stream | |
| 478 | - pending structured prompt | |
| 590 | 479 | |
| 591 | - provider capabilities schema | |
| 592 | - structured user-input prompt schema | |
| 593 | - structured user-input answer schema | |
| 594 | - mode metadata schema | |
| 480 | ### History state | |
| 595 | 481 | |
| 596 | ### Server/provider tests | |
| 482 | Render from: | |
| 597 | 483 | |
| 598 | Files: | |
| 484 | - normal messages | |
| 485 | - resolved prompt answers | |
| 486 | - plan-related activity/history rows | |
| 487 | - optional final-plan artifact | |
| 599 | 488 | |
| 600 | - `apps/server/src/provider/Layers/CodexAdapter.test.ts` | |
| 601 | - `apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts` | |
| 602 | - `apps/server/src/provider/Layers/CursorAdapter.test.ts` | |
| 603 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts` | |
| 489 | ### Placement | |
| 604 | 490 | |
| 605 | Add coverage for: | |
| 491 | Recommended initial placement: | |
| 606 | 492 | |
| 607 | - capabilities emission | |
| 608 | - mode propagation | |
| 609 | - plan update propagation | |
| 610 | - plan text propagation | |
| 611 | - structured prompt lifecycle | |
| 612 | - answer resolution lifecycle | |
| 613 | - reconnect/history replay behavior | |
| 493 | 1. Active pending prompt and active plan panel above the timeline, near the current approvals stack. | |
| 494 | 2. Resolved prompt/plan history in the timeline. | |
| 614 | 495 | |
| 615 | ### Web/UI tests | |
| 496 | This fits the existing `ChatView` layout with the least architectural churn. | |
| 497 | ||
| 498 | --- | |
| 499 | ||
| 500 | ## Test Plan | |
| 501 | ||
| 502 | ### Contracts | |
| 616 | 503 | |
| 617 | 504 | Add coverage for: |
| 618 | 505 | |
| 619 | - question card rendering from canonical prompt | |
| 620 | - question answer submission | |
| 621 | - resolved question history rendering | |
| 622 | - structured plan step rendering | |
| 623 | - text-only plan rendering | |
| 624 | - capability-based fallback rendering for Cursor | |
| 506 | - new interaction-mode schema | |
| 507 | - new capability schema | |
| 508 | - new structured-answer command schema | |
| 509 | - any read-model additions for active plan/prompt state | |
| 625 | 510 | |
| 626 | ### Probe / fixture tests | |
| 511 | Retain existing coverage for: | |
| 627 | 512 | |
| 628 | - preserve the Claude transcript-derived `AskUserQuestion` and `ExitPlanMode` payloads as fixtures | |
| 629 | - preserve Codex protocol fixtures for `turn/plan/updated`, `item/plan/delta`, and `item/tool/requestUserInput` | |
| 630 | - preserve ACP probe summaries as fixtures for unsupported-capability assertions | |
| 513 | - `turn.plan.updated` | |
| 514 | - `content.delta(plan_text)` | |
| 515 | - `user-input.requested` | |
| 516 | - `user-input.resolved` | |
| 631 | 517 | |
| 632 | --- | |
| 518 | ### Server | |
| 633 | 519 | |
| 634 | ## Assumptions and defaults | |
| 520 | Add or extend tests for: | |
| 635 | 521 | |
| 636 | - Codex is the reference provider for full structured plan/question UX. | |
| 637 | - Claude supports structured prompts via tool adaptation, but incremental structured plan updates are not assumed initially. | |
| 638 | - Cursor transport exposes `agent|plan|ask`, but product semantics collapse this to `operatingMode: default | plan`. | |
| 639 | - Cursor structured prompts/plan steps are driven by ACP extension methods when present, with fallback when absent. | |
| 640 | - Product/UI should degrade gracefully rather than invent unsupported provider behavior. | |
| 641 | - Raw provider payloads should always be retained where feasible to support future adapter refinement. | |
| 522 | - provider adapter plan/prompt mapping | |
| 523 | - `ProviderRuntimeIngestion` plan/prompt projection behavior | |
| 524 | - `ProjectionPipeline` persistence and replay | |
| 525 | - `ProjectionSnapshotQuery` returning active plan state | |
| 526 | - provider answer submission path | |
| 527 | ||
| 528 | ### Web | |
| 529 | ||
| 530 | Add or extend tests for: | |
| 531 | ||
| 532 | - `syncServerReadModel` with new plan fields | |
| 533 | - `session-logic.ts` derivation of active plan/prompt state | |
| 534 | - `ChatView` rendering of: | |
| 535 | - active plan panel | |
| 536 | - pending structured prompt | |
| 537 | - resolved prompt history | |
| 538 | - plan-text fallback | |
| 642 | 539 | |
| 643 | 540 | --- |
| 644 | 541 | |
| 645 | ## Recommended execution order | |
| 542 | ## Recommended Execution Order | |
| 543 | ||
| 544 | 1. Add shared contract types for interaction mode, capabilities, and structured-answer submission. | |
| 545 | 2. Extend orchestration read-model contracts and projection storage for active plan/prompt state. | |
| 546 | 3. Wire `ProviderRuntimeIngestion` into those projections. | |
| 547 | 4. Finish Codex end-to-end first, since it already emits most of the canonical runtime pieces. | |
| 548 | 5. Add frontend snapshot sync, derivation, and rendering in `types.ts`, `store.ts`, `session-logic.ts`, and `ChatView.tsx`. | |
| 549 | 6. Add Claude adapter mapping. | |
| 550 | 7. Add Cursor adapter mapping and fallback behavior. | |
| 551 | ||
| 552 | ## Short version | |
| 553 | ||
| 554 | The repo is already close in one specific sense: the runtime event vocabulary for plans and structured prompts exists. | |
| 555 | ||
| 556 | The real missing architecture is: | |
| 557 | ||
| 558 | - a separate plan/default operating mode concept | |
| 559 | - typed provider capabilities | |
| 560 | - a first-class structured-answer command path | |
| 561 | - read-model projection for active plan/prompt state | |
| 562 | - web rendering sourced from that snapshot state | |
| 646 | 563 | |
| 647 | 1. Formalize canonical capabilities + mode + structured prompt contracts | |
| 648 | 2. Wire capabilities/mode through orchestration and web socket projections | |
| 649 | 3. Finish Codex end-to-end plan mode implementation first | |
| 650 | 4. Implement Claude `AskUserQuestion` and `ExitPlanMode` adapter mapping | |
| 651 | 5. Add Cursor binary (`default|plan`) mode support + ACP extension-method mapping | |
| 652 | 6. Build capability-driven frontend rendering and answer submission | |
| 653 | 7. Add reconnect/history coverage and transcript/protocol fixtures | |
| 564 | That is the shape the implementation should follow. |
packages/contracts/src/ipc.ts+7-1| ... | ... | @@ -14,7 +14,12 @@ import type { |
| 14 | 14 | GitStatusInput, |
| 15 | 15 | GitStatusResult, |
| 16 | 16 | } from "./git"; |
| 17 | import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; | |
| 17 | import type { | |
| 18 | ProjectSearchEntriesInput, | |
| 19 | ProjectSearchEntriesResult, | |
| 20 | ProjectWriteFileInput, | |
| 21 | ProjectWriteFileResult, | |
| 22 | } from "./project"; | |
| 18 | 23 | import type { ServerConfig } from "./server"; |
| 19 | 24 | import type { |
| 20 | 25 | TerminalClearInput, |
| ... | ... | @@ -104,6 +109,7 @@ export interface NativeApi { |
| 104 | 109 | }; |
| 105 | 110 | projects: { |
| 106 | 111 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 112 | writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>; | |
| 107 | 113 | }; |
| 108 | 114 | shell: { |
| 109 | 115 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
packages/contracts/src/orchestration.test.ts+6-1| ... | ... | @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 5 | 6 | DEFAULT_RUNTIME_MODE, |
| 6 | 7 | OrchestrationGetTurnDiffInput, |
| 7 | 8 | OrchestrationSession, |
| ... | ... | @@ -110,6 +111,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () |
| 110 | 111 | }); |
| 111 | 112 | assert.strictEqual(parsed.provider, undefined); |
| 112 | 113 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); |
| 114 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | |
| 113 | 115 | }), |
| 114 | 116 | ); |
| 115 | 117 | |
| ... | ... | @@ -131,6 +133,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( |
| 131 | 133 | }); |
| 132 | 134 | assert.strictEqual(parsed.provider, "claudeCode"); |
| 133 | 135 | assert.strictEqual(parsed.runtimeMode, "full-access"); |
| 136 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | |
| 134 | 137 | }), |
| 135 | 138 | ); |
| 136 | 139 | |
| ... | ... | @@ -150,10 +153,11 @@ it.effect("accepts cursor provider in thread.turn.start", () => |
| 150 | 153 | createdAt: "2026-01-01T00:00:00.000Z", |
| 151 | 154 | }); |
| 152 | 155 | assert.strictEqual(parsed.provider, "cursor"); |
| 156 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | |
| 153 | 157 | }), |
| 154 | 158 | ); |
| 155 | 159 | |
| 156 | it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () => | |
| 160 | it.effect("decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () => | |
| 157 | 161 | Effect.gen(function* () { |
| 158 | 162 | const parsed = yield* decodeThreadTurnStartRequestedPayload({ |
| 159 | 163 | threadId: "thread-1", |
| ... | ... | @@ -162,6 +166,7 @@ it.effect("decodes thread.turn-start-requested defaults for provider and runtime |
| 162 | 166 | }); |
| 163 | 167 | assert.strictEqual(parsed.provider, undefined); |
| 164 | 168 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); |
| 169 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | |
| 165 | 170 | }), |
| 166 | 171 | ); |
| 167 | 172 |
packages/contracts/src/orchestration.ts+79| ... | ... | @@ -32,6 +32,9 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 32 | 32 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); |
| 33 | 33 | export type RuntimeMode = typeof RuntimeMode.Type; |
| 34 | 34 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 35 | export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); | |
| 36 | export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; | |
| 37 | export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; | |
| 35 | 38 | export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); |
| 36 | 39 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 37 | 40 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); |
| ... | ... | @@ -43,6 +46,8 @@ export const ProviderApprovalDecision = Schema.Literals([ |
| 43 | 46 | "cancel", |
| 44 | 47 | ]); |
| 45 | 48 | export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; |
| 49 | export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); | |
| 50 | export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; | |
| 46 | 51 | |
| 47 | 52 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 48 | 53 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; |
| ... | ... | @@ -219,6 +224,9 @@ export const OrchestrationThread = Schema.Struct({ |
| 219 | 224 | title: TrimmedNonEmptyString, |
| 220 | 225 | model: TrimmedNonEmptyString, |
| 221 | 226 | runtimeMode: RuntimeMode, |
| 227 | interactionMode: ProviderInteractionMode.pipe( | |
| 228 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | |
| 229 | ), | |
| 222 | 230 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 223 | 231 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 224 | 232 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| ... | ... | @@ -274,6 +282,9 @@ export const ThreadCreateCommand = Schema.Struct({ |
| 274 | 282 | title: TrimmedNonEmptyString, |
| 275 | 283 | model: TrimmedNonEmptyString, |
| 276 | 284 | runtimeMode: RuntimeMode, |
| 285 | interactionMode: ProviderInteractionMode.pipe( | |
| 286 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | |
| 287 | ), | |
| 277 | 288 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 278 | 289 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 279 | 290 | createdAt: IsoDateTime, |
| ... | ... | @@ -303,6 +314,14 @@ export const ThreadRuntimeModeSetCommand = Schema.Struct({ |
| 303 | 314 | createdAt: IsoDateTime, |
| 304 | 315 | }); |
| 305 | 316 | |
| 317 | export const ThreadInteractionModeSetCommand = Schema.Struct({ | |
| 318 | type: Schema.Literal("thread.interaction-mode.set"), | |
| 319 | commandId: CommandId, | |
| 320 | threadId: ThreadId, | |
| 321 | interactionMode: ProviderInteractionMode, | |
| 322 | createdAt: IsoDateTime, | |
| 323 | }); | |
| 324 | ||
| 306 | 325 | export const ThreadTurnStartCommand = Schema.Struct({ |
| 307 | 326 | type: Schema.Literal("thread.turn.start"), |
| 308 | 327 | commandId: CommandId, |
| ... | ... | @@ -318,6 +337,9 @@ export const ThreadTurnStartCommand = Schema.Struct({ |
| 318 | 337 | effort: Schema.optional(TrimmedNonEmptyString), |
| 319 | 338 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 320 | 339 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 340 | interactionMode: ProviderInteractionMode.pipe( | |
| 341 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | |
| 342 | ), | |
| 321 | 343 | createdAt: IsoDateTime, |
| 322 | 344 | }); |
| 323 | 345 | |
| ... | ... | @@ -336,6 +358,7 @@ export const ClientThreadTurnStartCommand = Schema.Struct({ |
| 336 | 358 | effort: Schema.optional(TrimmedNonEmptyString), |
| 337 | 359 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 338 | 360 | runtimeMode: RuntimeMode, |
| 361 | interactionMode: ProviderInteractionMode, | |
| 339 | 362 | createdAt: IsoDateTime, |
| 340 | 363 | }); |
| 341 | 364 | |
| ... | ... | @@ -356,6 +379,15 @@ export const ThreadApprovalRespondCommand = Schema.Struct({ |
| 356 | 379 | createdAt: IsoDateTime, |
| 357 | 380 | }); |
| 358 | 381 | |
| 382 | export const ThreadUserInputRespondCommand = Schema.Struct({ | |
| 383 | type: Schema.Literal("thread.user-input.respond"), | |
| 384 | commandId: CommandId, | |
| 385 | threadId: ThreadId, | |
| 386 | requestId: ApprovalRequestId, | |
| 387 | answers: ProviderUserInputAnswers, | |
| 388 | createdAt: IsoDateTime, | |
| 389 | }); | |
| 390 | ||
| 359 | 391 | export const ThreadCheckpointRevertCommand = Schema.Struct({ |
| 360 | 392 | type: Schema.Literal("thread.checkpoint.revert"), |
| 361 | 393 | commandId: CommandId, |
| ... | ... | @@ -379,9 +411,11 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 379 | 411 | ThreadDeleteCommand, |
| 380 | 412 | ThreadMetaUpdateCommand, |
| 381 | 413 | ThreadRuntimeModeSetCommand, |
| 414 | ThreadInteractionModeSetCommand, | |
| 382 | 415 | ThreadTurnStartCommand, |
| 383 | 416 | ThreadTurnInterruptCommand, |
| 384 | 417 | ThreadApprovalRespondCommand, |
| 418 | ThreadUserInputRespondCommand, | |
| 385 | 419 | ThreadCheckpointRevertCommand, |
| 386 | 420 | ThreadSessionStopCommand, |
| 387 | 421 | ]); |
| ... | ... | @@ -396,9 +430,11 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 396 | 430 | ThreadDeleteCommand, |
| 397 | 431 | ThreadMetaUpdateCommand, |
| 398 | 432 | ThreadRuntimeModeSetCommand, |
| 433 | ThreadInteractionModeSetCommand, | |
| 399 | 434 | ClientThreadTurnStartCommand, |
| 400 | 435 | ThreadTurnInterruptCommand, |
| 401 | 436 | ThreadApprovalRespondCommand, |
| 437 | ThreadUserInputRespondCommand, | |
| 402 | 438 | ThreadCheckpointRevertCommand, |
| 403 | 439 | ThreadSessionStopCommand, |
| 404 | 440 | ]); |
| ... | ... | @@ -485,10 +521,12 @@ export const OrchestrationEventType = Schema.Literals([ |
| 485 | 521 | "thread.deleted", |
| 486 | 522 | "thread.meta-updated", |
| 487 | 523 | "thread.runtime-mode-set", |
| 524 | "thread.interaction-mode-set", | |
| 488 | 525 | "thread.message-sent", |
| 489 | 526 | "thread.turn-start-requested", |
| 490 | 527 | "thread.turn-interrupt-requested", |
| 491 | 528 | "thread.approval-response-requested", |
| 529 | "thread.user-input-response-requested", | |
| 492 | 530 | "thread.checkpoint-revert-requested", |
| 493 | 531 | "thread.reverted", |
| 494 | 532 | "thread.session-stop-requested", |
| ... | ... | @@ -532,6 +570,9 @@ export const ThreadCreatedPayload = Schema.Struct({ |
| 532 | 570 | title: TrimmedNonEmptyString, |
| 533 | 571 | model: TrimmedNonEmptyString, |
| 534 | 572 | runtimeMode: RuntimeMode, |
| 573 | interactionMode: ProviderInteractionMode.pipe( | |
| 574 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | |
| 575 | ), | |
| 535 | 576 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 536 | 577 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 537 | 578 | createdAt: IsoDateTime, |
| ... | ... | @@ -558,6 +599,14 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({ |
| 558 | 599 | updatedAt: IsoDateTime, |
| 559 | 600 | }); |
| 560 | 601 | |
| 602 | export const ThreadInteractionModeSetPayload = Schema.Struct({ | |
| 603 | threadId: ThreadId, | |
| 604 | interactionMode: ProviderInteractionMode.pipe( | |
| 605 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | |
| 606 | ), | |
| 607 | updatedAt: IsoDateTime, | |
| 608 | }); | |
| 609 | ||
| 561 | 610 | export const ThreadMessageSentPayload = Schema.Struct({ |
| 562 | 611 | threadId: ThreadId, |
| 563 | 612 | messageId: MessageId, |
| ... | ... | @@ -578,6 +627,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 578 | 627 | effort: Schema.optional(TrimmedNonEmptyString), |
| 579 | 628 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 580 | 629 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 630 | interactionMode: ProviderInteractionMode.pipe( | |
| 631 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | |
| 632 | ), | |
| 581 | 633 | createdAt: IsoDateTime, |
| 582 | 634 | }); |
| 583 | 635 | |
| ... | ... | @@ -594,6 +646,13 @@ export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ |
| 594 | 646 | createdAt: IsoDateTime, |
| 595 | 647 | }); |
| 596 | 648 | |
| 649 | export const ThreadUserInputResponseRequestedPayload = Schema.Struct({ | |
| 650 | threadId: ThreadId, | |
| 651 | requestId: ApprovalRequestId, | |
| 652 | answers: ProviderUserInputAnswers, | |
| 653 | createdAt: IsoDateTime, | |
| 654 | }); | |
| 655 | ||
| 597 | 656 | export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ |
| 598 | 657 | threadId: ThreadId, |
| 599 | 658 | turnCount: NonNegativeInt, |
| ... | ... | @@ -702,6 +761,11 @@ export const OrchestrationEvent = Schema.Union([ |
| 702 | 761 | type: Schema.Literal("thread.runtime-mode-set"), |
| 703 | 762 | payload: ThreadRuntimeModeSetPayload, |
| 704 | 763 | }), |
| 764 | Schema.Struct({ | |
| 765 | ...EventBaseFields, | |
| 766 | type: Schema.Literal("thread.interaction-mode-set"), | |
| 767 | payload: ThreadInteractionModeSetPayload, | |
| 768 | }), | |
| 705 | 769 | Schema.Struct({ |
| 706 | 770 | ...EventBaseFields, |
| 707 | 771 | type: Schema.Literal("thread.message-sent"), |
| ... | ... | @@ -722,6 +786,11 @@ export const OrchestrationEvent = Schema.Union([ |
| 722 | 786 | type: Schema.Literal("thread.approval-response-requested"), |
| 723 | 787 | payload: ThreadApprovalResponseRequestedPayload, |
| 724 | 788 | }), |
| 789 | Schema.Struct({ | |
| 790 | ...EventBaseFields, | |
| 791 | type: Schema.Literal("thread.user-input-response-requested"), | |
| 792 | payload: ThreadUserInputResponseRequestedPayload, | |
| 793 | }), | |
| 725 | 794 | Schema.Struct({ |
| 726 | 795 | ...EventBaseFields, |
| 727 | 796 | type: Schema.Literal("thread.checkpoint-revert-requested"), |
| ... | ... | @@ -791,6 +860,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ |
| 791 | 860 | eventType: Schema.Literal("thread.runtime-mode-set"), |
| 792 | 861 | payload: ThreadRuntimeModeSetPayload, |
| 793 | 862 | }), |
| 863 | Schema.Struct({ | |
| 864 | ...PersistedEventBaseFields, | |
| 865 | eventType: Schema.Literal("thread.interaction-mode-set"), | |
| 866 | payload: ThreadInteractionModeSetPayload, | |
| 867 | }), | |
| 794 | 868 | Schema.Struct({ |
| 795 | 869 | ...PersistedEventBaseFields, |
| 796 | 870 | eventType: Schema.Literal("thread.message-sent"), |
| ... | ... | @@ -811,6 +885,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ |
| 811 | 885 | eventType: Schema.Literal("thread.approval-response-requested"), |
| 812 | 886 | payload: ThreadApprovalResponseRequestedPayload, |
| 813 | 887 | }), |
| 888 | Schema.Struct({ | |
| 889 | ...PersistedEventBaseFields, | |
| 890 | eventType: Schema.Literal("thread.user-input-response-requested"), | |
| 891 | payload: ThreadUserInputResponseRequestedPayload, | |
| 892 | }), | |
| 814 | 893 | Schema.Struct({ |
| 815 | 894 | ...PersistedEventBaseFields, |
| 816 | 895 | eventType: Schema.Literal("thread.checkpoint-revert-requested"), |
packages/contracts/src/project.ts+15| ... | ... | @@ -2,6 +2,7 @@ import { Schema } from "effect"; |
| 2 | 2 | import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | 3 | |
| 4 | 4 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 5 | export const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; | |
| 5 | 6 | |
| 6 | 7 | export const ProjectSearchEntriesInput = Schema.Struct({ |
| 7 | 8 | cwd: TrimmedNonEmptyString, |
| ... | ... | @@ -24,3 +25,17 @@ export const ProjectSearchEntriesResult = Schema.Struct({ |
| 24 | 25 | truncated: Schema.Boolean, |
| 25 | 26 | }); |
| 26 | 27 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
| 28 | ||
| 29 | export const ProjectWriteFileInput = Schema.Struct({ | |
| 30 | cwd: TrimmedNonEmptyString, | |
| 31 | relativePath: TrimmedNonEmptyString.check( | |
| 32 | Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH), | |
| 33 | ), | |
| 34 | contents: Schema.String, | |
| 35 | }); | |
| 36 | export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type; | |
| 37 | ||
| 38 | export const ProjectWriteFileResult = Schema.Struct({ | |
| 39 | relativePath: TrimmedNonEmptyString, | |
| 40 | }); | |
| 41 | export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; |
packages/contracts/src/provider.ts+10| ... | ... | @@ -14,8 +14,10 @@ import { |
| 14 | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 15 | 15 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 16 | 16 | ProviderApprovalDecision, |
| 17 | ProviderInteractionMode, | |
| 17 | 18 | ProviderKind, |
| 18 | 19 | ProviderRequestKind, |
| 20 | ProviderUserInputAnswers, | |
| 19 | 21 | RuntimeMode, |
| 20 | 22 | TurnCountRange, |
| 21 | 23 | } from "./orchestration"; |
| ... | ... | @@ -92,6 +94,7 @@ export const ProviderSendTurnInput = Schema.Struct({ |
| 92 | 94 | ), |
| 93 | 95 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 94 | 96 | effort: Schema.optional(TrimmedNonEmptyStringSchema), |
| 97 | interactionMode: Schema.optional(ProviderInteractionMode), | |
| 95 | 98 | }); |
| 96 | 99 | export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; |
| 97 | 100 | |
| ... | ... | @@ -169,6 +172,13 @@ export const ProviderRespondToRequestInput = Schema.Struct({ |
| 169 | 172 | }); |
| 170 | 173 | export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; |
| 171 | 174 | |
| 175 | export const ProviderRespondToUserInputInput = Schema.Struct({ | |
| 176 | threadId: ThreadId, | |
| 177 | requestId: ApprovalRequestId, | |
| 178 | answers: ProviderUserInputAnswers, | |
| 179 | }); | |
| 180 | export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; | |
| 181 | ||
| 172 | 182 | export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); |
| 173 | 183 | export type ProviderEventKind = typeof ProviderEventKind.Type; |
| 174 | 184 |
packages/contracts/src/ws.ts+3-1| ... | ... | @@ -29,7 +29,7 @@ import { |
| 29 | 29 | TerminalWriteInput, |
| 30 | 30 | } from "./terminal"; |
| 31 | 31 | import { KeybindingRule } from "./keybindings"; |
| 32 | import { ProjectSearchEntriesInput } from "./project"; | |
| 32 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; | |
| 33 | 33 | import { OpenInEditorInput } from "./editor"; |
| 34 | 34 | |
| 35 | 35 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| ... | ... | @@ -40,6 +40,7 @@ export const WS_METHODS = { |
| 40 | 40 | projectsAdd: "projects.add", |
| 41 | 41 | projectsRemove: "projects.remove", |
| 42 | 42 | projectsSearchEntries: "projects.searchEntries", |
| 43 | projectsWriteFile: "projects.writeFile", | |
| 43 | 44 | |
| 44 | 45 | // Shell methods |
| 45 | 46 | shellOpenInEditor: "shell.openInEditor", |
| ... | ... | @@ -101,6 +102,7 @@ export const WebSocketRequestBody = Schema.Union([ |
| 101 | 102 | |
| 102 | 103 | // Project Search |
| 103 | 104 | tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), |
| 105 | tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), | |
| 104 | 106 | |
| 105 | 107 | // Shell methods |
| 106 | 108 | tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), |