authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 14:16:31-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log12edc3455cf675d2e8000f508b38b79bc7743a88
tree7bd724608b2be8a5a58ec5de3340c8769dd27ba3
parent26f631b638205506d292c829feaf5d539e05804d

Add plan interaction mode and user-input request handling

- 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/contracts

57 files changed, 4625 insertions(+), 641 deletions(-)

apps/server/integration/TestProviderAdapter.integration.ts+7
......@@ -413,6 +413,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
413413 })
414414 : missingSessionEffect(provider, threadId);
415415
416 const respondToUserInput: ProviderAdapterShape<ProviderAdapterError>["respondToUserInput"] = (
417 threadId,
418 _requestId,
419 _answers,
420 ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId));
421
416422 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
417423 Effect.sync(() => {
418424 sessions.delete(threadId);
......@@ -475,6 +481,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
475481 sendTurn,
476482 interruptTurn,
477483 respondToRequest,
484 respondToUserInput,
478485 stopSession,
479486 listSessions,
480487 hasSession,
apps/server/integration/orchestrationEngine.integration.test.ts+6
......@@ -4,6 +4,7 @@ import path from "node:path";
44import {
55 ApprovalRequestId,
66 CommandId,
7 DEFAULT_PROVIDER_INTERACTION_MODE,
78 EventId,
89 MessageId,
910 ProjectId,
......@@ -118,6 +119,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118119 projectId: PROJECT_ID,
119120 title: "Integration Thread",
120121 model: "gpt-5-codex",
122 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
121123 runtimeMode: "approval-required",
122124 branch: null,
123125 worktreePath: harness.workspaceDir,
......@@ -143,6 +145,7 @@ const startTurn = (input: {
143145 attachments: [],
144146 },
145147 ...(input.provider !== undefined ? { provider: input.provider } : {}),
148 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
146149 runtimeMode: "approval-required",
147150 createdAt: nowIso(),
148151 });
......@@ -241,6 +244,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
241244 projectId: PROJECT_ID,
242245 title: "Integration Thread",
243246 model: "gpt-5.3-codex",
247 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
244248 runtimeMode: "full-access",
245249 branch: null,
246250 worktreePath: harness.workspaceDir,
......@@ -257,6 +261,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
257261 text: "Reply with exactly ALPHA.",
258262 attachments: [],
259263 },
264 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
260265 runtimeMode: "full-access",
261266 createdAt: nowIso(),
262267 });
......@@ -283,6 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
283288 text: "Reply with exactly BETA.",
284289 attachments: [],
285290 },
291 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
286292 runtimeMode: "approval-required",
287293 createdAt: nowIso(),
288294 });
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+2
......@@ -1,5 +1,6 @@
11import {
22 CheckpointRef,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
34 ProjectId,
45 ThreadId,
56 TurnId,
......@@ -43,6 +44,7 @@ function makeSnapshot(input: {
4344 projectId: input.projectId,
4445 title: "Thread",
4546 model: "gpt-5-codex",
47 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
4648 runtimeMode: "full-access",
4749 branch: null,
4850 worktreePath: input.worktreePath,
apps/server/src/codexAppServerManager.test.ts+129-1
......@@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto";
33import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
44import os from "node:os";
55import path from "node:path";
6import { ThreadId } from "@t3tools/contracts";
6import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
77
88import {
9 buildCodexInitializeParams,
10 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
911 CodexAppServerManager,
1012 classifyCodexStderrLine,
1113 isRecoverableThreadResumeError,
......@@ -22,6 +24,7 @@ function createSendTurnHarness() {
2224 provider: "codex",
2325 status: "ready",
2426 threadId: "thread_1",
27 resumeCursor: { threadId: "thread_1" },
2528 createdAt: "2026-02-10T00:00:00.000Z",
2629 updatedAt: "2026-02-10T00:00:00.000Z",
2730 },
......@@ -58,6 +61,7 @@ function createThreadControlHarness() {
5861 provider: "codex",
5962 status: "ready",
6063 threadId: "thread_1",
64 resumeCursor: { threadId: "thread_1" },
6165 createdAt: "2026-02-10T00:00:00.000Z",
6266 updatedAt: "2026-02-10T00:00:00.000Z",
6367 },
......@@ -80,6 +84,46 @@ function createThreadControlHarness() {
8084 return { manager, context, requireSession, sendRequest, updateSession };
8185}
8286
87function 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
83127describe("classifyCodexStderrLine", () => {
84128 it("ignores empty lines", () => {
85129 expect(classifyCodexStderrLine(" ")).toBeNull();
......@@ -151,6 +195,19 @@ describe("isRecoverableThreadResumeError", () => {
151195});
152196
153197describe("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
154211 it("emits session/startFailed when resolving cwd throws before process launch", async () => {
155212 const manager = new CodexAppServerManager();
156213 const events: Array<{ method: string; kind: string; message?: string }> = [];
......@@ -207,6 +264,7 @@ describe("sendTurn", () => {
207264 expect(result).toEqual({
208265 threadId: "thread_1",
209266 turnId: "turn_1",
267 resumeCursor: { threadId: "thread_1" },
210268 });
211269 expect(requireSession).toHaveBeenCalledWith("thread_1");
212270 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
......@@ -228,6 +286,7 @@ describe("sendTurn", () => {
228286 expect(updateSession).toHaveBeenCalledWith(context, {
229287 status: "running",
230288 activeTurnId: "turn_1",
289 resumeCursor: { threadId: "thread_1" },
231290 });
232291 });
233292
......@@ -255,6 +314,36 @@ describe("sendTurn", () => {
255314 });
256315 });
257316
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
258347 it("rejects empty turn input", async () => {
259348 const { manager } = createSendTurnHarness();
260349
......@@ -354,6 +443,45 @@ describe("thread checkpoint control", () => {
354443 });
355444});
356445
446describe("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
357485describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
358486 it(
359487 "keeps prior thread history when resuming with a changed runtime mode",
apps/server/src/codexAppServerManager.ts+281-15
......@@ -8,6 +8,7 @@ import {
88 EventId,
99 ProviderItemId,
1010 ProviderRequestKind,
11 type ProviderUserInputAnswers,
1112 ThreadId,
1213 TurnId,
1314 normalizeModelSlug,
......@@ -38,12 +39,25 @@ interface PendingApprovalRequest {
3839 itemId?: ProviderItemId;
3940}
4041
42interface PendingUserInputRequest {
43 requestId: ApprovalRequestId;
44 jsonRpcId: string | number;
45 threadId: ThreadId;
46 turnId?: TurnId;
47 itemId?: ProviderItemId;
48}
49
50interface CodexUserInputAnswer {
51 answers: string[];
52}
53
4154interface CodexSessionContext {
4255 session: ProviderSession;
4356 child: ChildProcessWithoutNullStreams;
4457 output: readline.Interface;
4558 pending: Map<PendingRequestKey, PendingRequest>;
4659 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;
60 pendingUserInputs: Map<ApprovalRequestId, PendingUserInputRequest>;
4761 nextRequestId: number;
4862 stopping: boolean;
4963}
......@@ -76,6 +90,7 @@ export interface CodexAppServerSendTurnInput {
7690 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
7791 readonly model?: string;
7892 readonly effort?: string;
93 readonly interactionMode?: "default" | "plan";
7994}
8095
8196export interface CodexThreadTurnSnapshot {
......@@ -103,6 +118,127 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
103118 "unknown thread",
104119 "does not exist",
105120];
121export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
122
123You 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
127You are in **Plan Mode** until a developer message explicitly ends it.
128
129Plan 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
133Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`<proposed_plan>\` block.
134
135Separately, \`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
139You 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
143Actions 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
152Actions 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
159When 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
163Begin 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
165Before 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
167Exception: 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
169Do 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
182Critical 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
188You 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
195Use 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
1991. **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
2062. **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
214Only output the final plan when it is decision complete and leaves no decisions to the implementer.
215
216When you present the official plan, wrap it in a \`<proposed_plan>\` block so the client can render it specially:
217
2181) The opening tag must be on its own line.
2192) Start the plan content on the next line (no text on the same line as the tag).
2203) The closing tag must be on its own line.
2214) Use Markdown inside the block.
2225) 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
224Example:
225
226<proposed_plan>
227plan content
228</proposed_plan>
229
230plan 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
238Do 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
240Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
241</collaboration_mode>`;
106242
107243function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): {
108244 readonly approvalPolicy: "on-request" | "never";
......@@ -154,6 +290,80 @@ export function normalizeCodexModelSlug(
154290 return normalized;
155291}
156292
293export 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
306function 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
334function 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
359function 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
157367export function classifyCodexStderrLine(rawLine: string): { message: string } | null {
158368 const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim();
159369 if (!line) {
......@@ -237,6 +447,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
237447 output,
238448 pending: new Map(),
239449 pendingApprovals: new Map(),
450 pendingUserInputs: new Map(),
240451 nextRequestId: 1,
241452 stopping: false,
242453 };
......@@ -246,16 +457,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
246457
247458 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");
248459
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());
259461
260462 this.writeMessage(context, { method: "initialized" });
261463
......@@ -423,17 +625,36 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
423625 >;
424626 model?: string;
425627 effort?: string;
628 collaborationMode?: {
629 mode: "plan";
630 settings: {
631 model: string;
632 reasoning_effort: string;
633 developer_instructions: string;
634 };
635 };
426636 } = {
427637 threadId: providerThreadId,
428638 input: turnInput,
429639 };
430 const normalizedModel = normalizeCodexModelSlug(input.model);
640 const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model);
431641 if (normalizedModel) {
432642 turnStartParams.model = normalizedModel;
433643 }
434644 if (input.effort) {
435645 turnStartParams.effort = input.effort;
436646 }
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 }
437658
438659 const response = await this.sendRequest(context, "turn/start", turnStartParams);
439660
......@@ -561,6 +782,43 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
561782 });
562783 }
563784
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
564822 stopSession(threadId: ThreadId): void {
565823 const context = this.sessions.get(threadId);
566824 if (!context) {
......@@ -575,6 +833,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
575833 }
576834 context.pending.clear();
577835 context.pendingApprovals.clear();
836 context.pendingUserInputs.clear();
578837
579838 context.output.close();
580839
......@@ -792,6 +1051,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
7921051 context.pendingApprovals.set(requestId, pendingRequest);
7931052 }
7941053
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
7951065 this.emitEvent({
7961066 id: EventId.makeUnsafe(randomUUID()),
7971067 kind: "request",
......@@ -811,10 +1081,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
8111081 }
8121082
8131083 if (request.method === "item/tool/requestUserInput") {
814 this.writeMessage(context, {
815 id: request.id,
816 result: { answers: {} },
817 });
8181084 return;
8191085 }
8201086
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+4
......@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
66import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
77import {
88 CommandId,
9 DEFAULT_PROVIDER_INTERACTION_MODE,
910 EventId,
1011 MessageId,
1112 ProjectId,
......@@ -85,6 +86,7 @@ function createProviderServiceHarness(
8586 sendTurn: () => unsupported(),
8687 interruptTurn: () => unsupported(),
8788 respondToRequest: () => unsupported(),
89 respondToUserInput: () => unsupported(),
8890 stopSession: () => unsupported(),
8991 listSessions,
9092 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
......@@ -286,6 +288,7 @@ describe("CheckpointReactor", () => {
286288 projectId: asProjectId("project-1"),
287289 title: "Thread",
288290 model: "gpt-5-codex",
291 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
289292 runtimeMode: "approval-required",
290293 branch: null,
291294 worktreePath: options?.threadWorktreePath ?? cwd,
......@@ -601,6 +604,7 @@ describe("CheckpointReactor", () => {
601604 text: "start turn",
602605 attachments: [],
603606 },
607 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
604608 runtimeMode: "approval-required",
605609 createdAt: new Date().toISOString(),
606610 }),
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+17-3
......@@ -1,6 +1,7 @@
11import {
22 CheckpointRef,
33 CommandId,
4 DEFAULT_PROVIDER_INTERACTION_MODE,
45 MessageId,
56 ProjectId,
67 ThreadId,
......@@ -80,6 +81,7 @@ describe("OrchestrationEngine", () => {
8081 projectId: asProjectId("project-1"),
8182 title: "Thread",
8283 model: "gpt-5-codex",
84 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
8385 runtimeMode: "approval-required",
8486 branch: null,
8587 worktreePath: null,
......@@ -97,6 +99,7 @@ describe("OrchestrationEngine", () => {
9799 text: "hello",
98100 attachments: [],
99101 },
102 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
100103 runtimeMode: "approval-required",
101104 createdAt,
102105 }),
......@@ -132,6 +135,7 @@ describe("OrchestrationEngine", () => {
132135 projectId: asProjectId("project-replay"),
133136 title: "replay",
134137 model: "gpt-5-codex",
138 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
135139 runtimeMode: "approval-required",
136140 branch: null,
137141 worktreePath: null,
......@@ -193,7 +197,8 @@ describe("OrchestrationEngine", () => {
193197 projectId: asProjectId("project-stream"),
194198 title: "domain-stream",
195199 model: "gpt-5-codex",
196 runtimeMode: "approval-required",
200 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
201 runtimeMode: "approval-required",
197202 branch: null,
198203 worktreePath: null,
199204 createdAt,
......@@ -237,6 +242,7 @@ describe("OrchestrationEngine", () => {
237242 projectId: asProjectId("project-turn-diff"),
238243 title: "Turn diff thread",
239244 model: "gpt-5-codex",
245 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
240246 runtimeMode: "approval-required",
241247 branch: null,
242248 worktreePath: null,
......@@ -345,7 +351,8 @@ describe("OrchestrationEngine", () => {
345351 projectId: asProjectId("project-flaky"),
346352 title: "flaky-fail",
347353 model: "gpt-5-codex",
348 runtimeMode: "approval-required",
354 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
355 runtimeMode: "approval-required",
349356 branch: null,
350357 worktreePath: null,
351358 createdAt,
......@@ -361,6 +368,7 @@ describe("OrchestrationEngine", () => {
361368 projectId: asProjectId("project-flaky"),
362369 title: "flaky-ok",
363370 model: "gpt-5-codex",
371 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
364372 runtimeMode: "approval-required",
365373 branch: null,
366374 worktreePath: null,
......@@ -425,6 +433,7 @@ describe("OrchestrationEngine", () => {
425433 projectId: asProjectId("project-atomic"),
426434 title: "atomic",
427435 model: "gpt-5-codex",
436 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
428437 runtimeMode: "approval-required",
429438 branch: null,
430439 worktreePath: null,
......@@ -442,6 +451,7 @@ describe("OrchestrationEngine", () => {
442451 text: "hello",
443452 attachments: [],
444453 },
454 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
445455 runtimeMode: "approval-required" as const,
446456 createdAt,
447457 };
......@@ -558,6 +568,7 @@ describe("OrchestrationEngine", () => {
558568 projectId: asProjectId("project-sync"),
559569 title: "sync-before",
560570 model: "gpt-5-codex",
571 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
561572 runtimeMode: "approval-required",
562573 branch: null,
563574 worktreePath: null,
......@@ -602,6 +613,7 @@ describe("OrchestrationEngine", () => {
602613 text: "hello",
603614 attachments: [],
604615 },
616 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
605617 runtimeMode: "approval-required",
606618 createdAt: now(),
607619 }),
......@@ -636,6 +648,7 @@ describe("OrchestrationEngine", () => {
636648 projectId: asProjectId("project-duplicate"),
637649 title: "duplicate",
638650 model: "gpt-5-codex",
651 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
639652 runtimeMode: "approval-required",
640653 branch: null,
641654 worktreePath: null,
......@@ -652,7 +665,8 @@ describe("OrchestrationEngine", () => {
652665 projectId: asProjectId("project-duplicate"),
653666 title: "duplicate",
654667 model: "gpt-5-codex",
655 runtimeMode: "approval-required",
668 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
669 runtimeMode: "approval-required",
656670 branch: null,
657671 worktreePath: null,
658672 createdAt,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16
......@@ -397,6 +397,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
397397 title: event.payload.title,
398398 model: event.payload.model,
399399 runtimeMode: event.payload.runtimeMode,
400 interactionMode: event.payload.interactionMode,
400401 branch: event.payload.branch,
401402 worktreePath: event.payload.worktreePath,
402403 latestTurnId: null,
......@@ -441,6 +442,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
441442 return;
442443 }
443444
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
444460 case "thread.deleted": {
445461 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
446462 const existingRow = yield* projectionThreadRepository.getById({
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
......@@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
234234 projectId: asProjectId("project-1"),
235235 title: "Thread 1",
236236 model: "gpt-5-codex",
237 interactionMode: "default",
237238 runtimeMode: "full-access",
238239 branch: null,
239240 worktreePath: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2
......@@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
154154 title,
155155 model,
156156 runtime_mode AS "runtimeMode",
157 interaction_mode AS "interactionMode",
157158 branch,
158159 worktree_path AS "worktreePath",
159160 latest_turn_id AS "latestTurnId",
......@@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
485486 title: row.title,
486487 model: row.model,
487488 runtimeMode: row.runtimeMode,
489 interactionMode: row.interactionMode,
488490 branch: row.branch,
489491 worktreePath: row.worktreePath,
490492 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";
66import {
77 ApprovalRequestId,
88 CommandId,
9 DEFAULT_PROVIDER_INTERACTION_MODE,
910 EventId,
1011 MessageId,
1112 ProjectId,
......@@ -139,6 +140,7 @@ describe("ProviderCommandReactor", () => {
139140 );
140141 const interruptTurn = vi.fn((_: unknown) => Effect.void);
141142 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
143 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
142144 const stopSession = vi.fn((input: unknown) =>
143145 Effect.sync(() => {
144146 const threadId =
......@@ -180,6 +182,7 @@ describe("ProviderCommandReactor", () => {
180182 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
181183 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
182184 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
185 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
183186 stopSession: stopSession as ProviderServiceShape["stopSession"],
184187 listSessions: () => Effect.succeed(runtimeSessions),
185188 getCapabilities: (provider) =>
......@@ -234,6 +237,7 @@ describe("ProviderCommandReactor", () => {
234237 projectId: asProjectId("project-1"),
235238 title: "Thread",
236239 model: "gpt-5-codex",
240 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
237241 runtimeMode: "approval-required",
238242 branch: null,
239243 worktreePath: null,
......@@ -247,6 +251,7 @@ describe("ProviderCommandReactor", () => {
247251 sendTurn,
248252 interruptTurn,
249253 respondToRequest,
254 respondToUserInput,
250255 stopSession,
251256 renameBranch,
252257 generateBranchName,
......@@ -269,6 +274,7 @@ describe("ProviderCommandReactor", () => {
269274 text: "hello reactor",
270275 attachments: [],
271276 },
277 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
272278 runtimeMode: "approval-required",
273279 createdAt: now,
274280 }),
......@@ -289,6 +295,44 @@ describe("ProviderCommandReactor", () => {
289295 expect(thread?.session?.runtimeMode).toBe("approval-required");
290296 });
291297
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
292336 it("starts first turn with requested provider when provider is specified", async () => {
293337 const harness = await createHarness();
294338 const now = new Date().toISOString();
......@@ -305,6 +349,7 @@ describe("ProviderCommandReactor", () => {
305349 attachments: [],
306350 },
307351 provider: "claudeCode",
352 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
308353 runtimeMode: "approval-required",
309354 createdAt: now,
310355 }),
......@@ -341,6 +386,7 @@ describe("ProviderCommandReactor", () => {
341386 attachments: [],
342387 },
343388 provider: "cursor",
389 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
344390 runtimeMode: "approval-required",
345391 createdAt: now,
346392 }),
......@@ -376,6 +422,7 @@ describe("ProviderCommandReactor", () => {
376422 text: "first",
377423 attachments: [],
378424 },
425 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
379426 runtimeMode: "approval-required",
380427 createdAt: now,
381428 }),
......@@ -395,6 +442,7 @@ describe("ProviderCommandReactor", () => {
395442 text: "second",
396443 attachments: [],
397444 },
445 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
398446 runtimeMode: "approval-required",
399447 createdAt: now,
400448 }),
......@@ -422,6 +470,7 @@ describe("ProviderCommandReactor", () => {
422470 },
423471 provider: "cursor",
424472 model: "composer-1.5",
473 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
425474 runtimeMode: "approval-required",
426475 createdAt: now,
427476 }),
......@@ -443,6 +492,7 @@ describe("ProviderCommandReactor", () => {
443492 },
444493 provider: "cursor",
445494 model: "composer-1.5",
495 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
446496 runtimeMode: "approval-required",
447497 createdAt: now,
448498 }),
......@@ -470,6 +520,7 @@ describe("ProviderCommandReactor", () => {
470520 },
471521 provider: "cursor",
472522 model: "gpt-5.3-codex",
523 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
473524 runtimeMode: "approval-required",
474525 createdAt: now,
475526 }),
......@@ -491,6 +542,7 @@ describe("ProviderCommandReactor", () => {
491542 },
492543 provider: "cursor",
493544 model: "composer-1.5",
545 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
494546 runtimeMode: "approval-required",
495547 createdAt: now,
496548 }),
......@@ -531,6 +583,7 @@ describe("ProviderCommandReactor", () => {
531583 text: "first",
532584 attachments: [],
533585 },
586 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
534587 runtimeMode: "full-access",
535588 createdAt: now,
536589 }),
......@@ -566,6 +619,7 @@ describe("ProviderCommandReactor", () => {
566619 text: "second",
567620 attachments: [],
568621 },
622 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
569623 runtimeMode: "full-access",
570624 createdAt: now,
571625 }),
......@@ -604,6 +658,7 @@ describe("ProviderCommandReactor", () => {
604658 text: "first",
605659 attachments: [],
606660 },
661 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
607662 runtimeMode: "approval-required",
608663 createdAt: now,
609664 }),
......@@ -624,6 +679,7 @@ describe("ProviderCommandReactor", () => {
624679 attachments: [],
625680 },
626681 provider: "claudeCode",
682 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
627683 runtimeMode: "approval-required",
628684 createdAt: now,
629685 }),
......@@ -672,6 +728,7 @@ describe("ProviderCommandReactor", () => {
672728 text: "first",
673729 attachments: [],
674730 },
731 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
675732 runtimeMode: "full-access",
676733 createdAt: now,
677734 }),
......@@ -746,7 +803,6 @@ describe("ProviderCommandReactor", () => {
746803 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);
747804 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({
748805 threadId: "thread-1",
749 turnId: "turn-1",
750806 });
751807 });
752808
......@@ -791,6 +847,51 @@ describe("ProviderCommandReactor", () => {
791847 });
792848 });
793849
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
794895 it("surfaces stale provider approval request failures without faking approval resolution", async () => {
795896 const harness = await createHarness();
796897 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+53
......@@ -31,6 +31,7 @@ type ProviderIntentEvent = Extract<
3131 | "thread.turn-start-requested"
3232 | "thread.turn-interrupt-requested"
3333 | "thread.approval-response-requested"
34 | "thread.user-input-response-requested"
3435 | "thread.session-stop-requested";
3536 }
3637>;
......@@ -147,6 +148,7 @@ const make = Effect.gen(function* () {
147148 | "provider.turn.start.failed"
148149 | "provider.turn.interrupt.failed"
149150 | "provider.approval.respond.failed"
151 | "provider.user-input.respond.failed"
150152 | "provider.session.stop.failed";
151153 readonly summary: string;
152154 readonly detail: string;
......@@ -320,6 +322,7 @@ const make = Effect.gen(function* () {
320322 readonly provider?: ProviderKind;
321323 readonly model?: string;
322324 readonly effort?: string;
325 readonly interactionMode?: "default" | "plan";
323326 readonly createdAt: string;
324327 }) {
325328 const thread = yield* resolveThread(input.threadId);
......@@ -348,6 +351,7 @@ const make = Effect.gen(function* () {
348351 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
349352 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
350353 ...(input.effort !== undefined ? { effort: input.effort } : {}),
354 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
351355 });
352356 });
353357
......@@ -461,6 +465,7 @@ const make = Effect.gen(function* () {
461465 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
462466 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
463467 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
468 interactionMode: event.payload.interactionMode,
464469 createdAt: event.payload.createdAt,
465470 });
466471 });
......@@ -535,6 +540,50 @@ const make = Effect.gen(function* () {
535540 );
536541 });
537542
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
538587 const processSessionStopRequested = Effect.fnUntraced(function* (
539588 event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>,
540589 ) {
......@@ -583,6 +632,9 @@ const make = Effect.gen(function* () {
583632 case "thread.approval-response-requested":
584633 yield* processApprovalResponseRequested(event);
585634 return;
635 case "thread.user-input-response-requested":
636 yield* processUserInputResponseRequested(event);
637 return;
586638 case "thread.session-stop-requested":
587639 yield* processSessionStopRequested(event);
588640 return;
......@@ -617,6 +669,7 @@ const make = Effect.gen(function* () {
617669 event.type !== "thread.turn-start-requested" &&
618670 event.type !== "thread.turn-interrupt-requested" &&
619671 event.type !== "thread.approval-response-requested" &&
672 event.type !== "thread.user-input-response-requested" &&
620673 event.type !== "thread.session-stop-requested"
621674 ) {
622675 return Effect.void;
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+264
......@@ -2,6 +2,7 @@ import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/cont
22import {
33 ApprovalRequestId,
44 CommandId,
5 DEFAULT_PROVIDER_INTERACTION_MODE,
56 EventId,
67 MessageId,
78 ProjectId,
......@@ -59,6 +60,7 @@ function createProviderServiceHarness() {
5960 sendTurn: () => unsupported(),
6061 interruptTurn: () => unsupported(),
6162 respondToRequest: () => unsupported(),
63 respondToUserInput: () => unsupported(),
6264 stopSession: () => unsupported(),
6365 listSessions: () => Effect.succeed([]),
6466 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
......@@ -163,6 +165,7 @@ describe("ProviderRuntimeIngestion", () => {
163165 projectId: asProjectId("project-1"),
164166 title: "Thread",
165167 model: "gpt-5-codex",
168 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
166169 runtimeMode: "approval-required",
167170 branch: null,
168171 worktreePath: null,
......@@ -630,6 +633,112 @@ describe("ProviderRuntimeIngestion", () => {
630633 expect(message?.streaming).toBe(false);
631634 });
632635
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
633742 it("buffers assistant deltas by default until completion", async () => {
634743 const harness = await createHarness();
635744 const now = new Date().toISOString();
......@@ -716,6 +825,7 @@ describe("ProviderRuntimeIngestion", () => {
716825 attachments: [],
717826 },
718827 assistantDeliveryMode: "streaming",
828 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
719829 runtimeMode: "approval-required",
720830 createdAt: now,
721831 }),
......@@ -1217,6 +1327,160 @@ describe("ProviderRuntimeIngestion", () => {
12171327 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");
12181328 });
12191329
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
12201484 it("continues processing runtime events after a single event handler failure", async () => {
12211485 const harness = await createHarness();
12221486 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
2626const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered";
2727const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
2828const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
29const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
30const MESSAGE_STREAM_KIND_BY_MESSAGE_ID_TTL = Duration.minutes(120);
2931const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
3032const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120);
3133const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;
......@@ -65,6 +67,14 @@ function truncateDetail(value: string, limit = 180): string {
6567 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
6668}
6769
70function 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
6878function asString(value: unknown): string | undefined {
6979 return typeof value === "string" ? value : undefined;
7080}
......@@ -168,6 +178,9 @@ function runtimeEventToActivities(
168178 })();
169179 switch (event.type) {
170180 case "request.opened": {
181 if (event.payload.requestType === "tool_user_input") {
182 return [];
183 }
171184 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
172185 return [
173186 {
......@@ -196,6 +209,9 @@ function runtimeEventToActivities(
196209 }
197210
198211 case "request.resolved": {
212 if (event.payload.requestType === "tool_user_input") {
213 return [];
214 }
199215 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
200216 return [
201217 {
......@@ -273,6 +289,111 @@ function runtimeEventToActivities(
273289 ];
274290 }
275291
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
276397 case "item.updated": {
277398 if (!isToolLifecycleItemType(event.payload.itemType)) {
278399 return [];
......@@ -359,6 +480,12 @@ const make = Effect.gen(function* () {
359480 lookup: () => Effect.succeed(new Set<MessageId>()),
360481 });
361482
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
362489 const bufferedAssistantTextByMessageId = yield* Cache.make<MessageId, string>({
363490 capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY,
364491 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,
......@@ -418,6 +545,16 @@ const make = Effect.gen(function* () {
418545 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
419546 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
420547
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
421558 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>
422559 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
423560 Effect.flatMap((existingText) =>
......@@ -451,7 +588,10 @@ const make = Effect.gen(function* () {
451588 Cache.invalidate(bufferedAssistantTextByMessageId, messageId);
452589
453590 const clearAssistantMessageState = (messageId: MessageId) =>
454 clearBufferedAssistantText(messageId);
591 Effect.all([
592 clearBufferedAssistantText(messageId),
593 Cache.invalidate(messageStreamKindByMessageId, messageId),
594 ]).pipe(Effect.asVoid);
455595
456596 const finalizeAssistantMessage = (input: {
457597 event: ProviderRuntimeEvent;
......@@ -464,9 +604,20 @@ const make = Effect.gen(function* () {
464604 fallbackText?: string;
465605 }) =>
466606 Effect.gen(function* () {
607 const messageStreamKind = yield* getMessageStreamKind(input.messageId);
467608 const bufferedText = yield* takeBufferedAssistantText(input.messageId);
609 const bufferedFinalText =
610 bufferedText.length > 0
611 ? messageStreamKind === "plan_text"
612 ? (wrapProposedPlanMessage(bufferedText) ?? "")
613 : bufferedText
614 : "";
468615 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 : "";
470621
471622 if (text.length > 0) {
472623 yield* orchestrationEngine.dispatch({
......@@ -617,7 +768,8 @@ const make = Effect.gen(function* () {
617768 }
618769
619770 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")
621773 ? event.payload.delta
622774 : undefined;
623775
......@@ -626,9 +778,14 @@ const make = Effect.gen(function* () {
626778 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
627779 );
628780 const turnId = toTurnId(event.turnId);
781 const messageStreamKind =
782 event.type === "content.delta" && event.payload.streamKind === "plan_text"
783 ? "plan_text"
784 : "assistant_text";
629785 if (turnId) {
630786 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
631787 }
788 yield* rememberMessageStreamKind(assistantMessageId, messageStreamKind);
632789
633790 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);
634791 if (assistantDeliveryMode === "buffered") {
......@@ -658,12 +815,16 @@ const make = Effect.gen(function* () {
658815 }
659816
660817 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")
662820 ? {
663821 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,
665826 }
666 : undefined;
827 : undefined;
667828
668829 if (assistantCompletion) {
669830 const assistantMessageId = assistantCompletion.messageId;
apps/server/src/orchestration/Schemas.ts+2
......@@ -5,6 +5,7 @@ import {
55 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
66 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
77 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
8 ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
89 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
910 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
1011 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -26,6 +27,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
2627export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
2728export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
2829export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
30export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
2931export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
3032
3133export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+6
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22import {
33 MessageId,
44 CommandId,
5 DEFAULT_PROVIDER_INTERACTION_MODE,
56 ProjectId,
67 ThreadId,
78 type OrchestrationCommand,
......@@ -50,6 +51,7 @@ const readModel: OrchestrationReadModel = {
5051 projectId: ProjectId.makeUnsafe("project-a"),
5152 title: "Thread A",
5253 model: "gpt-5-codex",
54 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
5355 runtimeMode: "full-access",
5456 branch: null,
5557 worktreePath: null,
......@@ -67,6 +69,7 @@ const readModel: OrchestrationReadModel = {
6769 projectId: ProjectId.makeUnsafe("project-b"),
6870 title: "Thread B",
6971 model: "gpt-5-codex",
72 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
7073 runtimeMode: "full-access",
7174 branch: null,
7275 worktreePath: null,
......@@ -92,6 +95,7 @@ const messageSendCommand: OrchestrationCommand = {
9295 text: "hello",
9396 attachments: [],
9497 },
98 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
9599 runtimeMode: "approval-required",
96100 createdAt: now,
97101};
......@@ -139,6 +143,7 @@ describe("commandInvariants", () => {
139143 projectId: ProjectId.makeUnsafe("project-a"),
140144 title: "new",
141145 model: "gpt-5-codex",
146 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
142147 runtimeMode: "full-access",
143148 branch: null,
144149 worktreePath: null,
......@@ -159,6 +164,7 @@ describe("commandInvariants", () => {
159164 projectId: ProjectId.makeUnsafe("project-a"),
160165 title: "dup",
161166 model: "gpt-5-codex",
167 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
162168 runtimeMode: "full-access",
163169 branch: null,
164170 worktreePath: null,
apps/server/src/orchestration/decider.projectScripts.test.ts+90-1
......@@ -1,4 +1,11 @@
1import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
1import {
2 CommandId,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
4 EventId,
5 MessageId,
6 ProjectId,
7 ThreadId,
8} from "@t3tools/contracts";
29import { describe, expect, it } from "vitest";
310import { Effect } from "effect";
411
......@@ -130,6 +137,7 @@ describe("decider project scripts", () => {
130137 projectId: asProjectId("project-1"),
131138 title: "Thread",
132139 model: "gpt-5-codex",
140 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
133141 runtimeMode: "approval-required",
134142 branch: null,
135143 worktreePath: null,
......@@ -154,6 +162,7 @@ describe("decider project scripts", () => {
154162 provider: "claudeCode",
155163 model: "gpt-5",
156164 effort: "high",
165 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
157166 runtimeMode: "approval-required",
158167 createdAt: now,
159168 },
......@@ -225,6 +234,7 @@ describe("decider project scripts", () => {
225234 projectId: asProjectId("project-1"),
226235 title: "Thread",
227236 model: "gpt-5-codex",
237 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
228238 runtimeMode: "full-access",
229239 branch: null,
230240 worktreePath: null,
......@@ -259,4 +269,83 @@ describe("decider project scripts", () => {
259269 },
260270 });
261271 });
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 });
262351});
apps/server/src/orchestration/decider.ts+53
......@@ -158,6 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
158158 title: command.title,
159159 model: command.model,
160160 runtimeMode: command.runtimeMode,
161 interactionMode: command.interactionMode,
161162 branch: command.branch,
162163 worktreePath: command.worktreePath,
163164 createdAt: command.createdAt,
......@@ -237,6 +238,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
237238 };
238239 }
239240
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
240264 case "thread.turn.start": {
241265 yield* requireThread({
242266 readModel,
......@@ -282,6 +306,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
282306 runtimeMode:
283307 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??
284308 command.runtimeMode,
309 interactionMode:
310 readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ??
311 command.interactionMode,
285312 createdAt: command.createdAt,
286313 },
287314 };
......@@ -336,6 +363,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
336363 };
337364 }
338365
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
339392 case "thread.checkpoint.revert": {
340393 yield* requireThread({
341394 readModel,
apps/server/src/orchestration/projector.ts+18
......@@ -16,6 +16,7 @@ import {
1616 ThreadActivityAppendedPayload,
1717 ThreadCreatedPayload,
1818 ThreadDeletedPayload,
19 ThreadInteractionModeSetPayload,
1920 ThreadMetaUpdatedPayload,
2021 ThreadRuntimeModeSetPayload,
2122 ThreadRevertedPayload,
......@@ -243,6 +244,7 @@ export function projectEvent(
243244 title: payload.title,
244245 model: payload.model,
245246 runtimeMode: payload.runtimeMode,
247 interactionMode: payload.interactionMode,
246248 branch: payload.branch,
247249 worktreePath: payload.worktreePath,
248250 latestTurn: null,
......@@ -307,6 +309,22 @@ export function projectEvent(
307309 })),
308310 );
309311
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
310328 case "thread.message-sent":
311329 return Effect.gen(function* () {
312330 const payload = yield* decodeForEvent(
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -25,6 +25,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
2525 title,
2626 model,
2727 runtime_mode,
28 interaction_mode,
2829 branch,
2930 worktree_path,
3031 latest_turn_id,
......@@ -38,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
3839 ${row.title},
3940 ${row.model},
4041 ${row.runtimeMode},
42 ${row.interactionMode},
4143 ${row.branch},
4244 ${row.worktreePath},
4345 ${row.latestTurnId},
......@@ -51,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
5153 title = excluded.title,
5254 model = excluded.model,
5355 runtime_mode = excluded.runtime_mode,
56 interaction_mode = excluded.interaction_mode,
5457 branch = excluded.branch,
5558 worktree_path = excluded.worktree_path,
5659 latest_turn_id = excluded.latest_turn_id,
......@@ -71,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
7174 title,
7275 model,
7376 runtime_mode AS "runtimeMode",
77 interaction_mode AS "interactionMode",
7478 branch,
7579 worktree_path AS "worktreePath",
7680 latest_turn_id AS "latestTurnId",
......@@ -93,6 +97,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
9397 title,
9498 model,
9599 runtime_mode AS "runtimeMode",
100 interaction_mode AS "interactionMode",
96101 branch,
97102 worktree_path AS "worktreePath",
98103 latest_turn_id AS "latestTurnId",
apps/server/src/persistence/Migrations.ts+2
......@@ -23,6 +23,7 @@ import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"
2323import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts";
2424import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";
2525import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";
26import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts";
2627import { Effect } from "effect";
2728
2829/**
......@@ -47,6 +48,7 @@ const loader = Migrator.fromRecord({
4748 "9_ProviderSessionRuntimeMode": Migration0009,
4849 "10_ProjectionThreadsRuntimeMode": Migration0010,
4950 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
51 "12_ProjectionThreadsInteractionMode": Migration0012,
5052});
5153
5254/**
apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts created+11
......@@ -0,0 +1,11 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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 @@
66 *
77 * @module ProjectionThreadRepository
88 */
9import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts";
9import {
10 IsoDateTime,
11 ProjectId,
12 ProviderInteractionMode,
13 RuntimeMode,
14 ThreadId,
15 TurnId,
16} from "@t3tools/contracts";
1017import { Option, Schema, ServiceMap } from "effect";
1118import type { Effect } from "effect";
1219
......@@ -18,6 +25,7 @@ export const ProjectionThread = Schema.Struct({
1825 title: Schema.String,
1926 model: Schema.String,
2027 runtimeMode: RuntimeMode,
28 interactionMode: ProviderInteractionMode,
2129 branch: Schema.NullOr(Schema.String),
2230 worktreePath: Schema.NullOr(Schema.String),
2331 latestTurnId: Schema.NullOr(TurnId),
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+14
......@@ -1781,6 +1781,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17811781 yield* Deferred.succeed(pending.decision, decision);
17821782 });
17831783
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
17841797 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>
17851798 Effect.gen(function* () {
17861799 const context = yield* requireSession(threadId);
......@@ -1830,6 +1843,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18301843 readThread,
18311844 rollbackThread,
18321845 respondToRequest,
1846 respondToUserInput,
18331847 stopSession,
18341848 listSessions,
18351849 hasSession,
apps/server/src/provider/Layers/CodexAdapter.test.ts+233
......@@ -7,6 +7,7 @@ import {
77 type ProviderSession,
88 type ProviderSessionStartInput,
99 type ProviderTurnStartResult,
10 type ProviderUserInputAnswers,
1011 ThreadId,
1112 TurnId,
1213} from "@t3tools/contracts";
......@@ -76,6 +77,14 @@ class FakeCodexManager extends CodexAppServerManager {
7677 ): Promise<void> => undefined,
7778 );
7879
80 public respondToUserInputImpl = vi.fn(
81 async (
82 _threadId: ThreadId,
83 _requestId: ApprovalRequestId,
84 _answers: ProviderUserInputAnswers,
85 ): Promise<void> => undefined,
86 );
87
7988 public stopAllImpl = vi.fn(() => undefined);
8089
8190 override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
......@@ -106,6 +115,14 @@ class FakeCodexManager extends CodexAppServerManager {
106115 return this.respondToRequestImpl(threadId, requestId, decision);
107116 }
108117
118 override respondToUserInput(
119 threadId: ThreadId,
120 requestId: ApprovalRequestId,
121 answers: ProviderUserInputAnswers,
122 ): Promise<void> {
123 return this.respondToUserInputImpl(threadId, requestId, answers);
124 }
125
109126 override stopSession(_threadId: ThreadId): void {}
110127
111128 override listSessions(): ProviderSession[] {
......@@ -253,6 +270,46 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
253270 }),
254271 );
255272
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
256313 it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () =>
257314 Effect.gen(function* () {
258315 const adapter = yield* CodexAdapter;
......@@ -361,6 +418,182 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
361418 }
362419 }),
363420 );
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 );
364597});
365598
366599afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+255
......@@ -11,11 +11,14 @@ import {
1111 type CanonicalRequestType,
1212 type ProviderEvent,
1313 type ProviderRuntimeEvent,
14 type ProviderUserInputAnswers,
1415 RuntimeItemId,
1516 RuntimeRequestId,
17 RuntimeTaskId,
1618 ProviderApprovalDecision,
1719 ProviderItemId,
1820 ThreadId,
21 TurnId,
1922} from "@t3tools/contracts";
2023import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect";
2124
......@@ -95,6 +98,10 @@ function asString(value: unknown): string | undefined {
9598 return typeof value === "string" ? value : undefined;
9699}
97100
101function asArray(value: unknown): unknown[] | undefined {
102 return Array.isArray(value) ? value : undefined;
103}
104
98105function asNumber(value: unknown): number | undefined {
99106 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
100107}
......@@ -249,6 +256,88 @@ function toRequestTypeFromResolvedPayload(
249256 return "unknown";
250257}
251258
259function 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
292function 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
252341function toThreadState(
253342 value: unknown,
254343): "active" | "idle" | "archived" | "closed" | "compacted" | "error" {
......@@ -304,6 +393,46 @@ function asRuntimeRequestId(requestId: string): RuntimeRequestId {
304393 return RuntimeRequestId.makeUnsafe(requestId);
305394}
306395
396function asRuntimeTaskId(taskId: string): RuntimeTaskId {
397 return RuntimeTaskId.makeUnsafe(taskId);
398}
399
400function codexEventMessage(payload: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
401 return asObject(payload?.msg);
402}
403
404function 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
307436function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent["raw"]>["source"] {
308437 return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification";
309438}
......@@ -404,6 +533,22 @@ function mapToRuntimeEvents(
404533 }
405534
406535 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
407552 const detail =
408553 asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt);
409554 return [
......@@ -737,6 +882,102 @@ function mapToRuntimeEvents(
737882 ];
738883 }
739884
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
740981 if (event.method === "model/rerouted") {
741982 return [
742983 {
......@@ -1034,6 +1275,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10341275 ...(input.input !== undefined ? { input: input.input } : {}),
10351276 ...(input.model !== undefined ? { model: input.model } : {}),
10361277 ...(input.effort !== undefined ? { effort: input.effort } : {}),
1278 ...(input.interactionMode !== undefined
1279 ? { interactionMode: input.interactionMode }
1280 : {}),
10371281 ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}),
10381282 };
10391283 return manager.sendTurn(managerInput);
......@@ -1096,6 +1340,16 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10961340 catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause),
10971341 });
10981342
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
10991353 const stopSession: CodexAdapterShape["stopSession"] = (threadId) =>
11001354 Effect.sync(() => {
11011355 manager.stopSession(threadId);
......@@ -1163,6 +1417,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
11631417 readThread,
11641418 rollbackThread,
11651419 respondToRequest,
1420 respondToUserInput,
11661421 stopSession,
11671422 listSessions,
11681423 hasSession,
apps/server/src/provider/Layers/CursorAdapter.ts+14
......@@ -1490,6 +1490,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14901490 });
14911491 });
14921492
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
14931506 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
14941507 Effect.gen(function* () {
14951508 const context = yield* requireSession(threadId);
......@@ -1539,6 +1552,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
15391552 readThread,
15401553 rollbackThread,
15411554 respondToRequest,
1555 respondToUserInput,
15421556 stopSession,
15431557 listSessions,
15441558 hasSession,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+3
......@@ -19,6 +19,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
1919 sendTurn: vi.fn(),
2020 interruptTurn: vi.fn(),
2121 respondToRequest: vi.fn(),
22 respondToUserInput: vi.fn(),
2223 stopSession: vi.fn(),
2324 listSessions: vi.fn(),
2425 hasSession: vi.fn(),
......@@ -35,6 +36,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
3536 sendTurn: vi.fn(),
3637 interruptTurn: vi.fn(),
3738 respondToRequest: vi.fn(),
39 respondToUserInput: vi.fn(),
3840 stopSession: vi.fn(),
3941 listSessions: vi.fn(),
4042 hasSession: vi.fn(),
......@@ -51,6 +53,7 @@ const fakeCursorAdapter: CursorAdapterShape = {
5153 sendTurn: vi.fn(),
5254 interruptTurn: vi.fn(),
5355 respondToRequest: vi.fn(),
56 respondToUserInput: vi.fn(),
5457 stopSession: vi.fn(),
5558 listSessions: vi.fn(),
5659 hasSession: vi.fn(),
apps/server/src/provider/Layers/ProviderService.test.ts+36-7
......@@ -116,6 +116,14 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
116116 ): Effect.Effect<void, ProviderAdapterError> => Effect.void,
117117 );
118118
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
119127 const stopSession = vi.fn(
120128 (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> =>
121129 Effect.sync(() => {
......@@ -172,6 +180,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
172180 sendTurn,
173181 interruptTurn,
174182 respondToRequest,
183 respondToUserInput,
175184 stopSession,
176185 listSessions,
177186 hasSession,
......@@ -192,6 +201,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
192201 sendTurn,
193202 interruptTurn,
194203 respondToRequest,
204 respondToUserInput,
195205 stopSession,
196206 listSessions,
197207 hasSession,
......@@ -395,12 +405,12 @@ it.effect(
395405 provider?: string;
396406 cwd?: string;
397407 resumeCursor?: unknown;
398 resumeThreadId?: string;
408 threadId?: string;
399409 };
400410 assert.equal(startPayload.provider, "codex");
401411 assert.equal(startPayload.cwd, "/tmp/project");
402412 assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor);
403 assert.equal(startPayload.resumeThreadId, "thread-1");
413 assert.equal(startPayload.threadId, startedSession.threadId);
404414 }
405415 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);
406416 const rollbackCall = secondCodex.rollbackThread.mock.calls[0];
......@@ -446,6 +456,23 @@ routing.layer("ProviderServiceLive routing", (it) => {
446456 [session.threadId, asRequestId("req-1"), "accept"],
447457 ]);
448458
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
449476 yield* provider.rollbackConversation({
450477 threadId: session.threadId,
451478 numTurns: 0,
......@@ -519,12 +546,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
519546 provider?: string;
520547 cwd?: string;
521548 resumeCursor?: unknown;
522 resumeThreadId?: string;
549 threadId?: string;
523550 };
524551 assert.equal(startPayload.provider, "codex");
525552 assert.equal(startPayload.cwd, "/tmp/project");
526553 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
527 assert.equal(startPayload.resumeThreadId, "thread-2");
554 assert.equal(startPayload.threadId, initial.threadId);
528555 }
529556 assert.equal(routing.codex.rollbackThread.mock.calls.length, 1);
530557 const rollbackCall = routing.codex.rollbackThread.mock.calls[0];
......@@ -561,12 +588,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
561588 provider?: string;
562589 cwd?: string;
563590 resumeCursor?: unknown;
564 resumeThreadId?: string;
591 threadId?: string;
565592 };
566593 assert.equal(startPayload.provider, "codex");
567594 assert.equal(startPayload.cwd, "/tmp/project-send-turn");
568595 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
569 assert.equal(startPayload.resumeThreadId, "thread-4");
596 assert.equal(startPayload.threadId, initial.threadId);
570597 }
571598 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
572599 }),
......@@ -629,7 +656,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
629656 };
630657 assert.equal(runtimePayload.cwd, process.cwd());
631658 assert.equal(runtimePayload.model, null);
632 assert.equal(runtimePayload.activeTurnId, "turn-1");
659 assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`);
633660 assert.equal(runtimePayload.lastError, null);
634661 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
635662 }
......@@ -822,7 +849,9 @@ validation.layer("ProviderServiceLive validation", (it) => {
822849
823850 const failure = yield* Effect.result(
824851 provider.startSession(asThreadId("thread-validation"), {
852 threadId: asThreadId("thread-validation"),
825853 provider: "invalid-provider",
854 runtimeMode: "full-access",
826855 } as never),
827856 );
828857
apps/server/src/provider/Layers/ProviderService.ts+17
......@@ -14,6 +14,7 @@ import {
1414 ThreadId,
1515 ProviderInterruptTurnInput,
1616 ProviderRespondToRequestInput,
17 ProviderRespondToUserInputInput,
1718 ProviderSendTurnInput,
1819 ProviderSessionStartInput,
1920 ProviderStopSessionInput,
......@@ -333,6 +334,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
333334 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);
334335 });
335336
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
336352 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
337353 Effect.gen(function* () {
338354 const input = yield* decodeInputOrValidationError({
......@@ -442,6 +458,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
442458 sendTurn,
443459 interruptTurn,
444460 respondToRequest,
461 respondToUserInput,
445462 stopSession,
446463 listSessions,
447464 getCapabilities,
apps/server/src/provider/Services/ProviderAdapter.ts+10
......@@ -11,6 +11,7 @@ import type {
1111 ApprovalRequestId,
1212 ProviderApprovalDecision,
1313 ProviderKind,
14 ProviderUserInputAnswers,
1415 ProviderRuntimeEvent,
1516 ProviderSendTurnInput,
1617 ProviderSession,
......@@ -79,6 +80,15 @@ export interface ProviderAdapterShape<TError> {
7980 decision: ProviderApprovalDecision,
8081 ) => Effect.Effect<void, TError>;
8182
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
8292 /**
8393 * Stop one provider session.
8494 */
apps/server/src/provider/Services/ProviderService.ts+8
......@@ -15,6 +15,7 @@ import type {
1515 ProviderInterruptTurnInput,
1616 ProviderKind,
1717 ProviderRespondToRequestInput,
18 ProviderRespondToUserInputInput,
1819 ProviderRuntimeEvent,
1920 ProviderSendTurnInput,
2021 ProviderSession,
......@@ -62,6 +63,13 @@ export interface ProviderServiceShape {
6263 input: ProviderRespondToRequestInput,
6364 ) => Effect.Effect<void, ProviderServiceError>;
6465
66 /**
67 * Respond to a provider structured user-input request.
68 */
69 readonly respondToUserInput: (
70 input: ProviderRespondToUserInputInput,
71 ) => Effect.Effect<void, ProviderServiceError>;
72
6573 /**
6674 * Stop a provider session.
6775 */
apps/server/src/wsServer.test.ts+54
......@@ -1126,6 +1126,8 @@ describe("WebSocket Server", () => {
11261126 projectId: "project-diff",
11271127 title: "Diff Thread",
11281128 model: "gpt-5-codex",
1129 runtimeMode: "full-access",
1130 interactionMode: "default",
11291131 branch: null,
11301132 worktreePath: null,
11311133 createdAt,
......@@ -1164,6 +1166,7 @@ describe("WebSocket Server", () => {
11641166 }),
11651167 interruptTurn: () => unsupported(),
11661168 respondToRequest: () => unsupported(),
1169 respondToUserInput: () => unsupported(),
11671170 stopSession: () => unsupported(),
11681171 listSessions: () => Effect.succeed([]),
11691172 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
......@@ -1202,6 +1205,8 @@ describe("WebSocket Server", () => {
12021205 projectId: "project-1",
12031206 title: "Thread 1",
12041207 model: "gpt-5-codex",
1208 runtimeMode: "full-access",
1209 interactionMode: "default",
12051210 branch: null,
12061211 worktreePath: null,
12071212 createdAt,
......@@ -1220,6 +1225,7 @@ describe("WebSocket Server", () => {
12201225 },
12211226 assistantDeliveryMode: "streaming",
12221227 runtimeMode: "approval-required",
1228 interactionMode: "default",
12231229 createdAt,
12241230 });
12251231 expect(startTurnResponse.error).toBeUndefined();
......@@ -1519,6 +1525,54 @@ describe("WebSocket Server", () => {
15191525 });
15201526 });
15211527
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
15221576 it("routes git core methods over websocket", async () => {
15231577 const listBranches = vi.fn(() =>
15241578 Effect.succeed({
apps/server/src/wsServer.ts+70
......@@ -12,6 +12,7 @@ import type { Duplex } from "node:stream";
1212import Mime from "@effect/platform-node/Mime";
1313import {
1414 CommandId,
15 DEFAULT_PROVIDER_INTERACTION_MODE,
1516 type ClientOrchestrationCommand,
1617 type OrchestrationCommand,
1718 ORCHESTRATION_WS_CHANNELS,
......@@ -146,6 +147,48 @@ function websocketRawToString(raw: unknown): string | null {
146147 return null;
147148}
148149
150function toPosixRelativePath(input: string): string {
151 return input.replaceAll("\\", "/");
152}
153
154function 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
149192function stripRequestTag<T extends { _tag: string }>(body: T) {
150193 return Struct.omit(body, ["_tag"]);
151194}
......@@ -595,6 +638,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
595638 projectId: bootstrapProjectId,
596639 title: "New thread",
597640 model: bootstrapProjectDefaultModel,
641 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
598642 runtimeMode: "full-access",
599643 branch: null,
600644 worktreePath: null,
......@@ -684,6 +728,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
684728 });
685729 }
686730
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
687757 case WS_METHODS.shellOpenInEditor: {
688758 const body = stripRequestTag(request.body);
689759 return yield* openInEditor(body);
apps/web/src/components/ChatView.tsx+1245-141
......@@ -41,6 +41,7 @@ import {
4141 useRef,
4242 useState,
4343 useTransition,
44 useId,
4445} from "react";
4546import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4647import { useDebouncedValue } from "@tanstack/react-pacer";
......@@ -57,17 +58,22 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer
5758import { isElectron } from "../env";
5859import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
5960import {
61 type ComposerSlashCommand,
6062 type ComposerTrigger,
6163 type ComposerTriggerKind,
6264 detectComposerTrigger,
6365 expandCollapsedComposerCursor,
66 parseStandaloneComposerSlashCommand,
6467 replaceTextRange,
6568} from "../composer-logic";
6669import {
6770 derivePendingApprovals,
71 derivePendingUserInputs,
6872 derivePhase,
6973 deriveTimelineEntries,
74 deriveActivePlanState,
7075 type PendingApproval,
76 type PendingUserInput,
7177 PROVIDER_OPTIONS,
7278 deriveWorkLogEntries,
7379 hasToolActivityForTurn,
......@@ -76,9 +82,24 @@ import {
7682 formatTimestamp,
7783} from "../session-logic";
7884import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";
85import {
86 buildPendingUserInputAnswers,
87 derivePendingUserInputProgress,
88 setPendingUserInputCustomAnswer,
89 type PendingUserInputDraftAnswer,
90} from "../pendingUserInput";
7991import { useStore } from "../store";
92import {
93 buildPlanImplementationThreadTitle,
94 buildPlanImplementationPrompt,
95 buildProposedPlanMarkdownFilename,
96 findLatestProposedPlanMessage,
97 parseProposedPlanMessage,
98 proposedPlanTitle,
99} from "../proposedPlan";
80100import { truncateTitle } from "../truncateTitle";
81101import {
102 DEFAULT_INTERACTION_MODE,
82103 DEFAULT_RUNTIME_MODE,
83104 DEFAULT_THREAD_TERMINAL_ID,
84105 MAX_THREAD_TERMINAL_COUNT,
......@@ -114,6 +135,7 @@ import {
114135 FileIcon,
115136 FolderIcon,
116137 DiffIcon,
138 EllipsisIcon,
117139 FolderClosedIcon,
118140 InfoIcon,
119141 LockIcon,
......@@ -124,13 +146,8 @@ import {
124146 CheckIcon,
125147} from "lucide-react";
126148import { Button } from "./ui/button";
127import {
128 Select,
129 SelectItem,
130 SelectPopup,
131 SelectTrigger,
132 SelectValue,
133} from "./ui/select";
149import { Input } from "./ui/input";
150import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
134151import { Separator } from "./ui/separator";
135152import { Group, GroupSeparator } from "./ui/group";
136153import {
......@@ -161,6 +178,16 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
161178import { Badge } from "./ui/badge";
162179import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
163180import { Command, CommandItem, CommandList } from "./ui/command";
181import {
182 Dialog,
183 DialogDescription,
184 DialogFooter,
185 DialogHeader,
186 DialogPanel,
187 DialogPopup,
188 DialogTitle,
189} from "./ui/dialog";
190import { toastManager } from "./ui/toast";
164191import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
165192import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
166193import {
......@@ -172,7 +199,7 @@ import {
172199} from "~/projectScripts";
173200import { Toggle } from "./ui/toggle";
174201import { SidebarTrigger } from "./ui/sidebar";
175import { newCommandId, newMessageId } from "~/lib/utils";
202import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
176203import { readNativeApi } from "~/nativeApi";
177204import { getAppModelOptions, useAppSettings } from "../appSettings";
178205import {
......@@ -206,6 +233,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
206233const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
207234const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
208235const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
236const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
209237const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
210238const SCRIPT_TERMINAL_COLS = 120;
211239const SCRIPT_TERMINAL_ROWS = 30;
......@@ -236,6 +264,22 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
236264 return "text-muted-foreground/40";
237265}
238266
267function normalizePlanMarkdownForExport(planMarkdown: string): string {
268 return `${planMarkdown.trimEnd()}\n`;
269}
270
271function 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
239283interface ExpandedImageItem {
240284 src: string;
241285 name: string;
......@@ -279,6 +323,7 @@ function buildLocalDraftThread(
279323 title: "New thread",
280324 model: fallbackModel,
281325 runtimeMode: draftThread.runtimeMode,
326 interactionMode: draftThread.interactionMode,
282327 session: null,
283328 messages: [],
284329 error,
......@@ -336,6 +381,7 @@ type ComposerCommandItem =
336381 | {
337382 id: string;
338383 type: "slash-command";
384 command: ComposerSlashCommand;
339385 label: string;
340386 description: string;
341387 }
......@@ -513,6 +559,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
513559 const threads = useStore((store) => store.threads);
514560 const projects = useStore((store) => store.projects);
515561 const markThreadVisited = useStore((store) => store.markThreadVisited);
562 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
516563 const setStoreThreadError = useStore((store) => store.setError);
517564 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
518565 const { settings } = useAppSettings();
......@@ -560,6 +607,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
560607 const [isConnecting, _setIsConnecting] = useState(false);
561608 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
562609 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>>({});
563618 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
564619 const [nowTick, setNowTick] = useState(() => Date.now());
565620 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
......@@ -652,8 +707,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
652707 );
653708 const activeThread = serverThread ?? localDraftThread;
654709 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
710 const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
655711 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);
712 const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode);
656713 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();
714 const [isInteractionModePending, startInteractionModeTransition] = useTransition();
657715 const isServerThread = serverThread !== undefined;
658716 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
659717 const diffSearch = useMemo(
......@@ -688,7 +746,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
688746 const selectedProviderByThreadId = composerDraft.provider;
689747 const hasThreadStarted = Boolean(
690748 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),
692752 );
693753 const lockedProvider: ProviderKind | null = hasThreadStarted
694754 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
......@@ -700,7 +760,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
700760 : null;
701761 const baseThreadModel = resolveModelSlugForProvider(
702762 selectedProvider,
703 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,
763 activeThread?.model ??
764 activeProject?.model ??
765 getDefaultModel(selectedProvider) ??
766 DEFAULT_MODEL,
704767 );
705768 const selectedModel = useMemo(() => {
706769 const draftModel = composerDraft.model;
......@@ -716,7 +779,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
716779
717780 const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider);
718781 if (normalizedDraftModel) {
719 const normalizedMatch = providerOptions.find((option) => option.slug === normalizedDraftModel);
782 const normalizedMatch = providerOptions.find(
783 (option) => option.slug === normalizedDraftModel,
784 );
720785 if (normalizedMatch) {
721786 return normalizedMatch.slug as ModelSlug;
722787 }
......@@ -732,19 +797,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
732797 [selectedModel, selectedProvider],
733798 );
734799 const selectedCursorModelCapabilities = useMemo(
735 () =>
736 selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null,
800 () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null),
737801 [selectedCursorModel],
738802 );
739803 const hasSelectedCursorTraits = Boolean(
740804 selectedCursorModelCapabilities &&
741 (selectedCursorModelCapabilities.supportsReasoning ||
742 selectedCursorModelCapabilities.supportsFast ||
743 selectedCursorModelCapabilities.supportsThinking),
805 (selectedCursorModelCapabilities.supportsReasoning ||
806 selectedCursorModelCapabilities.supportsFast ||
807 selectedCursorModelCapabilities.supportsThinking),
744808 );
745809 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 );
748817 const selectedModelForPickerWithCustomFallback = useMemo(() => {
749818 if (selectedProvider !== "cursor") {
750819 const currentOptions = modelOptionsByProvider[selectedProvider];
......@@ -794,6 +863,80 @@ export default function ChatView({ threadId }: ChatViewProps) {
794863 () => derivePendingApprovals(threadActivities),
795864 [threadActivities],
796865 );
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]);
797940 useEffect(() => {
798941 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
799942 }, [attachmentPreviewHandoffByMessageId]);
......@@ -1038,31 +1181,54 @@ export default function ChatView({ threadId }: ChatViewProps) {
10381181 }
10391182
10401183 if (composerTrigger.kind === "slash-command") {
1041 if (!"model".includes(composerTrigger.query.toLowerCase())) {
1042 return [];
1043 }
1044 return [
1184 const slashCommandItems = [
10451185 {
10461186 id: "slash:model",
10471187 type: "slash-command",
1188 command: "model",
10481189 label: "/model",
10491190 description: "Switch response model for this thread",
10501191 },
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 );
10521214 }
10531215
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 }));
10661232 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
10671233 const composerMenuOpen = Boolean(composerTrigger);
10681234 const activeComposerMenuItem = useMemo(
......@@ -1444,6 +1610,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
14441610 });
14451611 };
14461612
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
14471648 useEffect(() => {
14481649 try {
14491650 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
......@@ -2070,7 +2271,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
20702271 e?.preventDefault();
20712272 const api = readNativeApi();
20722273 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
2274 if (activePendingProgress) {
2275 onAdvanceActivePendingUserInput();
2276 return;
2277 }
20732278 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 }
20742307 if (!trimmed && composerImages.length === 0) return;
20752308 if (!activeProject) return;
20762309 const threadIdForSend = activeThread.id;
......@@ -2183,7 +2416,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
21832416 }
21842417 }
21852418 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;
21872421
21882422 if (isLocalDraftThread) {
21892423 await api.orchestration.dispatchCommand({
......@@ -2194,6 +2428,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
21942428 title,
21952429 model: threadCreateModel,
21962430 runtimeMode,
2431 interactionMode,
21972432 branch: nextThreadBranch,
21982433 worktreePath: nextThreadWorktreePath,
21992434 createdAt: activeThread.createdAt,
......@@ -2254,6 +2489,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
22542489 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),
22552490 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
22562491 runtimeMode,
2492 interactionMode,
22572493 createdAt: messageCreatedAt,
22582494 });
22592495 turnStartSucceeded = true;
......@@ -2337,6 +2573,345 @@ export default function ChatView({ threadId }: ChatViewProps) {
23372573 [activeThreadId, setStoreThreadError],
23382574 );
23392575
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
23402915 const onProviderModelSelect = useCallback(
23412916 (provider: ProviderKind, model: ModelSlug) => {
23422917 if (!activeThread) return;
......@@ -2452,7 +3027,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
24523027 }
24533028 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
24543029 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 }
24563045 setComposerCursor(next.cursor);
24573046 setComposerTrigger(detectComposerTrigger(next.text, next.cursor));
24583047 window.requestAnimationFrame(() => {
......@@ -2460,7 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24603049 });
24613050 return true;
24623051 },
2463 [setPrompt],
3052 [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt],
24643053 );
24653054
24663055 const readComposerSnapshot = useCallback((): { value: string; cursor: number } => {
......@@ -2506,7 +3095,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
25063095 return;
25073096 }
25083097 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, "", {
25103109 expectedText: expectedToken,
25113110 });
25123111 if (applied) {
......@@ -2522,7 +3121,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
25223121 setComposerHighlightedItemId(null);
25233122 }
25243123 },
2525 [applyPromptReplacement, onProviderModelSelect, resolveActiveComposerTrigger],
3124 [
3125 applyPromptReplacement,
3126 handleInteractionModeChange,
3127 onProviderModelSelect,
3128 resolveActiveComposerTrigger,
3129 ],
25263130 );
25273131 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
25283132 setComposerHighlightedItemId(itemId);
......@@ -2553,6 +3157,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
25533157
25543158 const onPromptChange = useCallback(
25553159 (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 }
25563169 promptRef.current = nextPrompt;
25573170 setPrompt(nextPrompt);
25583171 setComposerCursor(nextCursor);
......@@ -2565,7 +3178,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
25653178 ),
25663179 );
25673180 },
2568 [setPrompt],
3181 [
3182 activePendingProgress?.activeQuestion,
3183 activePendingUserInput,
3184 onChangeActivePendingUserInputCustomAnswer,
3185 setPrompt,
3186 ],
25693187 );
25703188
25713189 const onComposerCommandKey = (
......@@ -2698,6 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
26983316 respondingRequestIds={respondingRequestIds}
26993317 onRespondToApproval={onRespondToApproval}
27003318 />
3319 <PlanModePanel activePlan={activePlan} />
27013320
27023321 {/* Messages */}
27033322 <div
......@@ -2734,6 +3353,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27343353 onImageExpand={onExpandTimelineImage}
27353354 markdownCwd={gitCwd ?? undefined}
27363355 resolvedTheme={resolvedTheme}
3356 workspaceRoot={activeProject?.cwd ?? undefined}
27373357 />
27383358 </div>
27393359
......@@ -2754,8 +3374,32 @@ export default function ChatView({ threadId }: ChatViewProps) {
27543374 onDragLeave={onComposerDragLeave}
27553375 onDrop={onComposerDrop}
27563376 >
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
27573396 {/* 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 >
27593403 {composerMenuOpen && (
27603404 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
27613405 <ComposerCommandMenu
......@@ -2770,7 +3414,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27703414 </div>
27713415 )}
27723416
2773 {composerImages.length > 0 && (
3417 {pendingUserInputs.length === 0 && composerImages.length > 0 && (
27743418 <div className="mb-3 flex flex-wrap gap-2">
27753419 {composerImages.map((image) => (
27763420 <div
......@@ -2836,15 +3480,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
28363480 )}
28373481 <ComposerPromptEditor
28383482 ref={composerEditorRef}
2839 value={prompt}
3483 value={activePendingProgress ? activePendingProgress.customAnswer : prompt}
28403484 cursor={composerCursor}
28413485 onChange={onPromptChange}
28423486 onCommandKeyDown={onComposerCommandKey}
28433487 onPaste={onComposerPaste}
28443488 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"
28483496 }
28493497 disabled={isConnecting}
28503498 />
......@@ -2890,39 +3538,44 @@ export default function ChatView({ threadId }: ChatViewProps) {
28903538 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
28913539 )}
28923540
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}
29103575 />
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 )}
29263579 </>
29273580 ) : supportsReasoningEffort ? (
29283581 <>
......@@ -2943,6 +3596,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
29433596 {/* Divider */}
29443597 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
29453598
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
29463626 {/* Runtime mode toggle */}
29473627 <Button
29483628 variant="ghost"
......@@ -2952,9 +3632,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29523632 disabled={isRuntimeModePending}
29533633 onClick={() =>
29543634 void handleRuntimeModeChange(
2955 optimisticRuntimeMode === "full-access"
2956 ? "approval-required"
2957 : "full-access",
3635 optimisticRuntimeMode === "full-access" ? "approval-required" : "full-access",
29583636 )
29593637 }
29603638 title={
......@@ -2975,10 +3653,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
29753653 {isPreparingWorktree ? (
29763654 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
29773655 ) : 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" ? (
29793688 <button
29803689 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"
29823691 onClick={() => void onInterrupt()}
29833692 aria-label="Stop generation"
29843693 >
......@@ -2992,61 +3701,110 @@ export default function ChatView({ threadId }: ChatViewProps) {
29923701 <rect x="2" y="2" width="8" height="8" rx="1.5" />
29933702 </svg>
29943703 </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}
30203712 >
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>
30313715 ) : (
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}
30503808 </div>
30513809 </div>
30523810 </div>
......@@ -3326,7 +4084,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
33264084 ? "Command approval requested"
33274085 : approval.requestKind === "file-read"
33284086 ? "File-read approval requested"
3329 : "File-change approval requested"}
4087 : "File-change approval requested"}
33304088 </AlertTitle>
33314089 <AlertDescription
33324090 className="truncate block font-mono text-[11px]"
......@@ -3375,6 +4133,159 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
33754133 );
33764134});
33774135
4136interface PlanModePanelProps {
4137 activePlan: ReturnType<typeof deriveActivePlanState>;
4138}
4139
4140const 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
4185interface PendingUserInputPanelProps {
4186 pendingUserInputs: PendingUserInput[];
4187 respondingRequestIds: ApprovalRequestId[];
4188 answers: Record<string, PendingUserInputDraftAnswer>;
4189 questionIndex: number;
4190 onSelectOption: (questionId: string, optionLabel: string) => void;
4191}
4192
4193const 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
4217const 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
4268const 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
33784289const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
33794290 const [copied, setCopied] = useState(false);
33804291
......@@ -3537,6 +4448,191 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
35374448 return (
35384449 <div className="space-y-0.5">
35394450 {treeNodes.map((node) => renderTreeNode(node, 0))}
4451const 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}
35404636 </div>
35414637 );
35424638});
......@@ -3561,6 +4657,7 @@ interface MessagesTimelineProps {
35614657 onImageExpand: (preview: ExpandedImagePreview) => void;
35624658 markdownCwd: string | undefined;
35634659 resolvedTheme: "light" | "dark";
4660 workspaceRoot: string | undefined;
35644661}
35654662
35664663type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
......@@ -3602,6 +4699,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
36024699 onImageExpand,
36034700 markdownCwd,
36044701 resolvedTheme,
4702 workspaceRoot,
36054703}: MessagesTimelineProps) {
36064704 const timelineRootRef = useRef<HTMLDivElement | null>(null);
36074705 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
......@@ -3946,9 +5044,10 @@ const MessagesTimeline = memo(function MessagesTimeline({
39465044 </div>
39475045 )}
39485046 <div className="min-w-0 px-1 py-0.5">
3949 <ChatMarkdown
5047 <ProposedPlanMessage
39505048 text={messageText}
39515049 cwd={markdownCwd}
5050 workspaceRoot={workspaceRoot}
39525051 isStreaming={Boolean(row.message.streaming)}
39535052 />
39545053 {(() => {
......@@ -4201,7 +5300,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
42015300 return (
42025301 <MenuSub key={option.value}>
42035302 <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 />
42055307 {option.label}
42065308 </MenuSubTrigger>
42075309 <MenuSubPopup className="[--available-height:min(24rem,70vh)]">
......@@ -4354,7 +5456,9 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
43545456 </MenuGroup>
43555457 )}
43565458 {props.capabilities.supportsReasoning &&
4357 (props.capabilities.supportsFast || props.capabilities.supportsThinking) && <MenuDivider />}
5459 (props.capabilities.supportsFast || props.capabilities.supportsThinking) && (
5460 <MenuDivider />
5461 )}
43585462 {props.capabilities.supportsFast && (
43595463 <MenuGroup>
43605464 <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 {
44 detectComposerTrigger,
55 expandCollapsedComposerCursor,
66 isCollapsedCursorAdjacentToMention,
7 parseStandaloneComposerSlashCommand,
78 replaceTextRange,
89} from "./composer-logic";
910
......@@ -43,6 +44,18 @@ describe("detectComposerTrigger", () => {
4344 rangeEnd: text.length,
4445 });
4546 });
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 });
4659});
4760
4861describe("replaceTextRange", () => {
......@@ -111,3 +124,17 @@ describe("isCollapsedCursorAdjacentToMention", () => {
111124 expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false);
112125 });
113126});
127
128describe("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 @@
11import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22
33export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default";
45
56export interface ComposerTrigger {
67 kind: ComposerTriggerKind;
......@@ -9,6 +10,8 @@ export interface ComposerTrigger {
910 rangeEnd: number;
1011}
1112
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
14
1215function clampCursor(text: string, cursor: number): number {
1316 if (!Number.isFinite(cursor)) return text.length;
1417 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
......@@ -121,7 +124,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
121124 rangeEnd: cursor,
122125 };
123126 }
124 if ("model".startsWith(commandQuery.toLowerCase())) {
127 if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) {
125128 return {
126129 kind: "slash-command",
127130 query: commandQuery,
......@@ -157,6 +160,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
157160 };
158161}
159162
163export 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
160174export function replaceTextRange(
161175 text: string,
162176 rangeStart: number,
apps/web/src/composerDraftStore.ts+23-1
......@@ -6,9 +6,14 @@ import {
66 normalizeModelSlug,
77 type CodexReasoningEffort,
88 type ProviderKind,
9 type ProviderInteractionMode,
910 type RuntimeMode,
1011} from "@t3tools/contracts";
11import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
12import {
13 DEFAULT_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,
15 type ChatImageAttachment,
16} from "./types";
1217import { create } from "zustand";
1318import { createJSONStorage, persist } from "zustand/middleware";
1419
......@@ -40,6 +45,7 @@ interface PersistedDraftThreadState {
4045 projectId: ProjectId;
4146 createdAt: string;
4247 runtimeMode: RuntimeMode;
48 interactionMode: ProviderInteractionMode;
4349 branch: string | null;
4450 worktreePath: string | null;
4551 envMode: DraftThreadEnvMode;
......@@ -65,6 +71,7 @@ export interface DraftThreadState {
6571 projectId: ProjectId;
6672 createdAt: string;
6773 runtimeMode: RuntimeMode;
74 interactionMode: ProviderInteractionMode;
6875 branch: string | null;
6976 worktreePath: string | null;
7077 envMode: DraftThreadEnvMode;
......@@ -89,6 +96,7 @@ interface ComposerDraftStoreState {
8996 createdAt?: string;
9097 envMode?: DraftThreadEnvMode;
9198 runtimeMode?: RuntimeMode;
99 interactionMode?: ProviderInteractionMode;
92100 },
93101 ) => void;
94102 setDraftThreadContext: (
......@@ -100,6 +108,7 @@ interface ComposerDraftStoreState {
100108 createdAt?: string;
101109 envMode?: DraftThreadEnvMode;
102110 runtimeMode?: RuntimeMode;
111 interactionMode?: ProviderInteractionMode;
103112 },
104113 ) => void;
105114 clearProjectDraftThreadId: (projectId: ProjectId) => void;
......@@ -270,6 +279,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
270279 candidateDraftThread.runtimeMode === "full-access"
271280 ? candidateDraftThread.runtimeMode
272281 : DEFAULT_RUNTIME_MODE,
282 interactionMode:
283 candidateDraftThread.interactionMode === "plan" ||
284 candidateDraftThread.interactionMode === "default"
285 ? candidateDraftThread.interactionMode
286 : DEFAULT_INTERACTION_MODE,
273287 branch: typeof branch === "string" ? branch : null,
274288 worktreePath: normalizedWorktreePath,
275289 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
......@@ -297,6 +311,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
297311 projectId: projectId as ProjectId,
298312 createdAt: new Date().toISOString(),
299313 runtimeMode: DEFAULT_RUNTIME_MODE,
314 interactionMode: DEFAULT_INTERACTION_MODE,
300315 branch: null,
301316 worktreePath: null,
302317 envMode: "local",
......@@ -500,6 +515,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
500515 projectId,
501516 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
502517 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
518 interactionMode:
519 options?.interactionMode ??
520 existingThread?.interactionMode ??
521 DEFAULT_INTERACTION_MODE,
503522 branch:
504523 options?.branch === undefined
505524 ? (existingThread?.branch ?? null)
......@@ -515,6 +534,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
515534 existingThread.projectId === nextDraftThread.projectId &&
516535 existingThread.createdAt === nextDraftThread.createdAt &&
517536 existingThread.runtimeMode === nextDraftThread.runtimeMode &&
537 existingThread.interactionMode === nextDraftThread.interactionMode &&
518538 existingThread.branch === nextDraftThread.branch &&
519539 existingThread.worktreePath === nextDraftThread.worktreePath &&
520540 existingThread.envMode === nextDraftThread.envMode;
......@@ -570,6 +590,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
570590 ? existing.createdAt
571591 : options.createdAt || existing.createdAt,
572592 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
593 interactionMode: options.interactionMode ?? existing.interactionMode,
573594 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
574595 worktreePath: nextWorktreePath,
575596 envMode:
......@@ -580,6 +601,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
580601 nextDraftThread.projectId === existing.projectId &&
581602 nextDraftThread.createdAt === existing.createdAt &&
582603 nextDraftThread.runtimeMode === existing.runtimeMode &&
604 nextDraftThread.interactionMode === existing.interactionMode &&
583605 nextDraftThread.branch === existing.branch &&
584606 nextDraftThread.worktreePath === existing.worktreePath &&
585607 nextDraftThread.envMode === existing.envMode;
apps/web/src/pendingUserInput.test.ts created+192
......@@ -0,0 +1,192 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildPendingUserInputAnswers,
5 countAnsweredPendingUserInputQuestions,
6 derivePendingUserInputProgress,
7 findFirstUnansweredPendingUserInputQuestionIndex,
8 resolvePendingUserInputAnswer,
9 setPendingUserInputCustomAnswer,
10} from "./pendingUserInput";
11
12describe("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
45describe("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
110describe("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 @@
1import type { UserInputQuestion } from "@t3tools/contracts";
2
3export interface PendingUserInputDraftAnswer {
4 selectedOptionLabel?: string;
5 customAnswer?: string;
6}
7
8export 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
22function 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
31export 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
42export 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
55export 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
72export 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
81export 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
92export 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 @@
1import { MessageId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import {
5 buildPlanImplementationThreadTitle,
6 buildPlanImplementationPrompt,
7 buildProposedPlanMarkdownFilename,
8 findLatestProposedPlanMessage,
9 parseProposedPlanMessage,
10 proposedPlanTitle,
11} from "./proposedPlan";
12
13describe("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
31describe("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
83describe("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
93describe("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
101describe("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
113describe("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 @@
1import type { MessageId } from "@t3tools/contracts";
2
3import type { ChatMessage } from "./types";
4
5export interface ProposedPlanBlock {
6 beforeText: string;
7 planMarkdown: string;
8 afterText: string;
9}
10
11export interface ProposedPlanMessageMatch {
12 message: ChatMessage;
13 plan: ProposedPlanBlock;
14}
15
16const PROPOSED_PLAN_REGEX = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/;
17
18export 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
40export 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
45function 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
54export 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
91export function buildPlanImplementationPrompt(planMarkdown: string): string {
92 return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`;
93}
94
95export function buildPlanImplementationThreadTitle(planMarkdown: string): string {
96 const title = proposedPlanTitle(planMarkdown);
97 if (!title) {
98 return "Implement plan";
99 }
100 return `Implement ${title}`;
101}
102
103export 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
22import { describe, expect, it } from "vitest";
33
44import {
5 deriveActivePlanState,
56 PROVIDER_OPTIONS,
67 derivePendingApprovals,
8 derivePendingUserInputs,
79 deriveWorkLogEntries,
810 hasToolActivityForTurn,
911 isLatestTurnSettled,
......@@ -131,6 +133,130 @@ describe("derivePendingApprovals", () => {
131133 });
132134});
133135
136describe("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
222describe("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
134260describe("deriveWorkLogEntries", () => {
135261 it("omits tool started entries and keeps completed entries", () => {
136262 const activities: OrchestrationThreadActivity[] = [
apps/web/src/session-logic.ts+166
......@@ -3,6 +3,7 @@ import {
33 type OrchestrationLatestTurn,
44 type OrchestrationThreadActivity,
55 type ProviderKind,
6 type UserInputQuestion,
67 type TurnId,
78} from "@t3tools/contracts";
89
......@@ -33,6 +34,22 @@ export interface PendingApproval {
3334 detail?: string;
3435}
3536
37export interface PendingUserInput {
38 requestId: ApprovalRequestId;
39 createdAt: string;
40 questions: ReadonlyArray<UserInputQuestion>;
41}
42
43export 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
3653export type TimelineEntry =
3754 | {
3855 id: string;
......@@ -161,6 +178,155 @@ export function derivePendingApprovals(
161178 );
162179}
163180
181function 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
230export 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
269export 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
164330export function deriveWorkLogEntries(
165331 activities: ReadonlyArray<OrchestrationThreadActivity>,
166332 latestTurnId: TurnId | undefined,
apps/web/src/store.test.ts+3-1
......@@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too
22import { describe, expect, it } from "vitest";
33
44import { markThreadUnread, syncServerReadModel, type AppState } from "./store";
5import { DEFAULT_RUNTIME_MODE, type Thread } from "./types";
5import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
88 return {
......@@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1212 title: "Thread",
1313 model: "gpt-5-codex",
1414 runtimeMode: DEFAULT_RUNTIME_MODE,
15 interactionMode: DEFAULT_INTERACTION_MODE,
1516 session: null,
1617 messages: [],
1718 turnDiffSummaries: [],
......@@ -49,6 +50,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
4950 title: "Thread",
5051 model: "gpt-5.3-codex",
5152 runtimeMode: DEFAULT_RUNTIME_MODE,
53 interactionMode: DEFAULT_INTERACTION_MODE,
5254 branch: null,
5355 worktreePath: null,
5456 latestTurn: null,
apps/web/src/store.ts+1
......@@ -257,6 +257,7 @@ export function syncServerReadModel(
257257 thread.model,
258258 ),
259259 runtimeMode: thread.runtimeMode,
260 interactionMode: thread.interactionMode,
260261 session: thread.session
261262 ? {
262263 provider: toLegacyProvider(thread.session.providerName),
apps/web/src/types.ts+4
......@@ -9,11 +9,14 @@ import type {
99 MessageId,
1010 CheckpointRef,
1111 ProviderKind,
12 ProviderInteractionMode,
1213} from "@t3tools/contracts";
1314
1415export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
1516export type RuntimeMode = "approval-required" | "full-access";
1617export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
18export type InteractionMode = ProviderInteractionMode;
19export const DEFAULT_INTERACTION_MODE: InteractionMode = "default";
1720export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
1821export const DEFAULT_THREAD_TERMINAL_ID = "default";
1922export const MAX_THREAD_TERMINAL_COUNT = 4;
......@@ -78,6 +81,7 @@ export interface Thread {
7881 title: string;
7982 model: string;
8083 runtimeMode: RuntimeMode;
84 interactionMode: InteractionMode;
8185 session: ThreadSession | null;
8286 messages: ChatMessage[];
8387 error: string | null;
apps/web/src/worktreeCleanup.test.ts+2-1
......@@ -1,7 +1,7 @@
11import { ProjectId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import { DEFAULT_RUNTIME_MODE, type Thread } from "./types";
4import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
55import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
......@@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1212 title: "Thread",
1313 model: "gpt-5.3-codex",
1414 runtimeMode: DEFAULT_RUNTIME_MODE,
15 interactionMode: DEFAULT_INTERACTION_MODE,
1516 session: null,
1617 messages: [],
1718 turnDiffSummaries: [],
apps/web/src/wsNativeApi.test.ts+19
......@@ -6,6 +6,7 @@ import {
66 ProjectId,
77 ThreadId,
88 WS_CHANNELS,
9 WS_METHODS,
910 type ServerProviderStatus,
1011} from "@t3tools/contracts";
1112import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
......@@ -330,6 +331,24 @@ describe("wsNativeApi", () => {
330331 });
331332 });
332333
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
333352 it("forwards full-thread diff requests to the orchestration websocket method", async () => {
334353 requestMock.mockResolvedValue({ diff: "patch" });
335354 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+1
......@@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi {
141141 },
142142 projects: {
143143 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
144 writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input),
144145 },
145146 shell: {
146147 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
22
33## Goal
44
5Implement plan mode in a provider-agnostic way across:
5Implement plan mode without fighting the architecture that already exists in this repo.
66
7- Codex App Server
8- Claude Agent SDK / Claude Code
9- Cursor ACP
7The current app is:
108
11The 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
1213
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
14This document updates the implementation plan to match that reality.
1715
18This document now serves as both:
16---
1917
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
2219
23---
20### 1. Shared contracts already have some plan primitives
2421
25## Source of truth
22`packages/contracts/src/providerRuntime.ts` already includes:
2623
27### Codex
24- `turn.plan.updated`
25- `content.delta` with `streamKind: "plan_text"`
26- `user-input.requested`
27- `user-input.resolved`
2828
29Codex protocol decisions should be based on the open-source app-server protocol, not on local rollout session files.
29Important constraint:
3030
31Upstream 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
3235
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
3837
39Important implication:
38`RuntimeMode` in `packages/contracts/src/orchestration.ts` and `packages/contracts/src/provider.ts` means execution policy:
4039
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`
4442
45### Claude
43Plan mode must not overload that field.
4644
47Claude protocol decisions should be based on:
45If we need provider operating mode, it needs a separate concept and separate naming, for example:
4846
49- installed SDK typings
50- real local session JSONL evidence
47- `interactionMode`
48- `planningMode`
49- `providerOperatingMode`
5150
52Confirmed from local Claude transcript:
51Anything is acceptable as long as it is not reused from the existing access-policy `runtimeMode`.
5352
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
5754
58### Cursor
55The current server pipeline is:
5956
60Cursor protocol decisions should be based on:
571. Provider adapters emit `ProviderRuntimeEvent`s.
582. `ProviderService` multiplexes adapter streams and maintains provider session bindings.
593. `ProviderRuntimeIngestion` translates runtime events into orchestration commands and thread activities.
604. `ProjectionPipeline` persists read-model tables.
615. `ProjectionSnapshotQuery` rebuilds the orchestration snapshot returned by `orchestration.getSnapshot`.
626. `wsServer` exposes generic orchestration snapshot, command dispatch, diff, and replay methods.
6163
62- ACP docs
63- current local ACP probe output
64This matters because plan mode should fit into the same pipeline, not bypass it with a parallel websocket protocol.
6465
65Confirmed from current docs + probe:
66Important detail:
6667
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
7470
75---
71### 4. The current read model has nowhere to put plan mode
7672
77## High-level product model
73`OrchestrationThread` currently contains:
7874
79The 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
8081
81### 1. Operating mode
82It does not contain:
8283
83This 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
8491
85Canonical operating mode:
92Today the only flexible place plan data can survive into the web snapshot is `activities[].payload`.
8693
87- `default`
88- `plan`
94### 5. The web app is snapshot-driven, not event-reduced
8995
90This should be represented at runtime independently of plan content or prompt cards.
96The client flow today is:
9197
92### 2. Structured plan state
981. `EventRouter` listens to `orchestration.domainEvent`.
992. On domain events, it re-fetches `orchestration.getSnapshot`.
1003. Zustand stores the latest normalized snapshot.
1014. `session-logic.ts` derives UI state like pending approvals and work log from thread activities.
1025. `ChatView` renders the thread from snapshot state plus those derived selectors.
93103
94This is the current structured plan snapshot, if the provider can supply one natively or if the adapter can synthesize one confidently.
104Important implication:
95105
96Canonical 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
97108
98```ts
99interface 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
109110
110### 3. Structured user-input prompt
111#### Codex
111112
112This is the product-level question card model.
113Currently implemented:
113114
114Canonical model:
115- `turn/plan/updated` maps to `turn.plan.updated`
116- `item/plan/delta` maps to `content.delta` with `streamKind: "plan_text"`
115117
116```ts
117interface 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}
118Current gap:
139119
140interface 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
149123
150This should remain distinct from approvals.
124#### Claude Code
151125
152---
126Currently implemented:
153127
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`
155131
156## Summary
132Current gap:
157133
158Implement 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
159137
160### Shared goals
138#### Cursor
161139
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
140Currently 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
146Current gap:
147
148- no current plan-mode mapping
149- no current structured prompt mapping
150- no current todo/plan projection
167151
168152---
169153
170## Orchestration work items
154## Design Constraints
171155
172### 1. Extend canonical provider runtime contracts
156### Keep the current transport model
173157
174Files:
158Plan mode should continue to flow through:
175159
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`
179165
180Add or formalize the following runtime concepts:
166Do not introduce provider-specific websocket channels unless the current architecture proves insufficient.
181167
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
187169
188Required additions:
170The client already assumes that server state is re-synced from snapshots. Plan mode should respect that.
189171
190```ts
191interface ProviderInteractiveCapabilities {
192 supportsPlanMode: boolean;
193 supportsStructuredPlanUpdates: boolean;
194 supportsPlanTextStreaming: boolean;
195 supportsStructuredUserInput: boolean;
196 supportsFreeformUserInput: boolean;
197 supportsExitPlanMode: boolean;
198}
199```
172That means:
200173
201Canonical 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
202177
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
207179
208### 2. Add canonical mode/capability publication from providers
180The current system has a full approval pipeline:
209181
210Files:
182- runtime `request.opened` / `request.resolved`
183- orchestration `thread.approval.respond`
184- provider `respondToRequest(...)`
211185
212- `apps/server/src/provider/Layers/*Adapter.ts`
213- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
186Plan-mode structured prompts are different and should not be forced into the approval model unless a provider genuinely models them as approval requests.
214187
215Each adapter should publish provider capabilities and current operating mode early in session startup and when mode changes.
188### Preserve raw provider payloads
216189
217Preferred shape:
190When adding plan-mode mappings:
218191
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
221195
222### 3. Preserve and project plan/user-input state in orchestration
196This does not mean provider-specific payloads should leak into the orchestration read model, websocket API, or web UI.
223197
224Files:
198The boundary should stay:
225199
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
229202
230Implementation requirements:
203That matters most for Claude and Cursor, where some mappings will be adapter-derived and may need native context during implementation and debugging.
231204
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---
236206
237### 4. Add frontend state model for plan mode
207## Updated Shared Work Plan
238208
239Files likely involved:
209## 1. Tighten shared contract naming before wiring features
240210
241- web app session/thread stores
242- conversation/event rendering layer
243- input composer / pending interaction surfaces
211The first step is not to invent a new architecture. It is to formalize the concepts that are currently missing from contracts.
244212
245Add frontend state for:
213### Add a plan operating mode concept
246214
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
215Add a new shared contract for provider plan/default mode.
253216
254### 5. Implement capability-driven UI rendering
217Requirements:
255218
256UI 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
257222
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
223Preferred shape:
263224
264### 6. Implement front-to-back response submission path
225```ts
226type ProviderInteractionMode = "default" | "plan";
227```
265228
266Requirements:
229### Add typed provider interactive capabilities
267230
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
231Current `session.configured.payload.config` is just `Record<string, unknown>`. That is too weak for capability-driven UI.
273232
274### 7. History and reconnect behavior
233Add a typed capability surface in `packages/contracts` that both server and web can import directly.
275234
276Requirements:
235Preferred shape:
277236
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
282240
283---
241Minimum fields:
284242
285## Provider adapter plans
243```ts
244interface ProviderInteractiveCapabilities {
245 supportsPlanMode: boolean;
246 supportsStructuredPlanUpdates: boolean;
247 supportsPlanTextStreaming: boolean;
248 supportsStructuredUserInput: boolean;
249 supportsFreeformUserInput: boolean;
250 supportsPlanAcceptance: boolean;
251}
252```
286253
287## Provider 1: Codex App Server
254Preferred usage:
288255
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
290259
291Codex is the reference implementation and should be wired as the strongest native provider.
260### Add a dedicated structured-answer command path
292261
293### Native protocol mapping
262Current shared commands only support approvals:
294263
295Source of truth: upstream open-source app-server protocol.
264- `thread.approval.respond`
265- provider `respondToRequest(...)`
296266
297Native incoming surfaces:
267Plan mode needs a distinct path for structured prompt answers.
298268
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`
269Preferred additions:
306270
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
308274
309Files:
275Do not overload approval decisions like `accept` / `decline` for structured question answers.
310276
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
313278
314Implementation requirements:
279The current read model is the main missing piece.
315280
3161. Keep native structured plan updates as-is:
317- `turn/plan/updated` -> `turn.plan.updated`
281Plan mode needs first-class projection state so reconnect and refresh behave like the rest of the app.
318282
3192. Keep native plan text streaming as-is:
320- `item/plan/delta` -> `content.delta` with `streamKind: "plan_text"`
283### Add first-class thread plan state
321284
3223. Keep native structured user-input as-is:
323- `item/tool/requestUserInput` -> `user-input.requested`
324- also emit `request.opened` with a transport request type
285Preferred thread-level additions:
325286
3264. 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`
330292
3315. 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
293The exact nesting can vary, but it should be snapshot-friendly and not require replaying arbitrary activity payloads in the client.
338294
339### Codex-specific tests
295`interactiveCapabilities` should not be modeled as thread-owned state if they are static per provider.
340296
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
297Preferred split:
346298
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
348301
349## Provider 2: Claude Agent SDK / Claude Code
302### Keep activities as secondary audit trail
350303
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.
352305
353Claude 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
354307
355### Real native evidence
308This likely means:
356309
357From 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
358314
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
369316
370Claude also has native:
317`ProviderRuntimeIngestion` already knows how to:
371318
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
375322
376### Adapter implementation
323It should be extended to also translate plan/user-input runtime events into read-model state.
377324
378Files:
325Implementation direction:
379326
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
382332
383Implementation requirements:
333## 4. Integrate plan mode into the existing web seams
384334
3851. Publish native operating mode:
386- `permissionMode: 'plan'` -> canonical mode `plan`
387- all other values -> canonical mode `default`
335The web architecture already has the right places.
388336
3892. 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
393338
3943. 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`
339Update:
401340
4024. 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`
406343
4075. 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
344The store should stay thin and snapshot-oriented. It should sync whatever new read-model fields the server exposes.
411345
4126. 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
418347
4197. 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`
348Update:
426349
427### Claude-specific tests
350- `apps/web/src/session-logic.ts`
428351
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
352This is the natural place for:
435353
436### Claude-specific open question to resolve during implementation
354- `derivePlanState(...)`
355- `derivePendingUserInput(...)`
356- any fallback derivation from activities during an incremental migration
437357
438- exact runtime shape expected for the answer payload returned to `AskUserQuestion`
358### Rendering
439359
440Implementation default:
360Primary UI seam:
441361
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`
443363
444---
364Current UI already has:
445365
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
447369
448### Summary
370That suggests two viable rendering patterns:
449371
450Cursor 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.
3721. active plan/prompt panel above the timeline
3732. resolved plan/prompt entries rendered in timeline/history
451374
452### Real native evidence
375The route structure does not need to change. Plan mode should remain thread-local state inside the existing chat route.
453376
454From the Cursor ACP docs + current probe:
377---
455378
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
469380
470Current caveats:
381## Provider 1: Codex
471382
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
383Codex is closest to the target shape and should be the first end-to-end implementation.
475384
476### Adapter implementation
385### Current status
477386
478Files:
387Already present:
479388
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
483391
484Implementation requirements:
392Missing:
485393
4861. 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
490398
4912. 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
495400
4963. 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
4011. Upgrade `item/tool/requestUserInput` from request-only handling to canonical `user-input.requested`.
4022. Preserve `request.opened` as transport/debug metadata when useful.
4033. Add adapter-side answer submission for the corresponding pending request/tool context.
4044. Emit `user-input.resolved` when the answer is submitted or confirmed resolved.
4055. Project the result into the orchestration snapshot.
500406
5014. 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
504408
5055. 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`
409The runtime event schemas already match Codex reasonably well, so this provider should define the canonical end-to-end behavior first.
512410
513### Cursor-specific tests
411## Provider 2: Claude Code
514412
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
413Claude likely needs tool-aware adaptation rather than pure transport mapping.
521414
522### Cursor-specific follow-up probe work
415### Current status
523416
524Add dedicated probes for:
417Already present:
525418
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
530421
531---
422Missing:
532423
533## Frontend implementation plan
424- plan/default operating mode mapping
425- `AskUserQuestion` -> `user-input.requested`
426- `ExitPlanMode` -> final-plan or plan-state handoff
534427
535### Summary
428### Required work
536429
537Render plan mode through a single UI model driven by canonical events and provider capabilities.
4301. Detect plan/default operating mode from Claude-native metadata without touching access-policy `runtimeMode`.
4312. Map `AskUserQuestion` tool payloads into canonical `user-input.requested`.
4323. Add provider-specific answer serialization for the return path.
4334. Detect `ExitPlanMode` and decide whether it becomes:
434 - a final-plan artifact
435 - a synthetic structured plan snapshot
436 - or both
4375. Avoid over-synthesizing incremental structured plan steps unless Claude gives us enough structure to do it safely.
538438
539### UI states to support
439## Provider 3: Cursor
540440
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
441Cursor should start from the same canonical server pipeline, but with a more conservative adapter.
547442
548### Rendering rules
443### Current status
549444
5501. Show operating mode indicator whenever current mode is `plan`
5512. Show structured question card only when a pending `user-input.requested` exists
5523. Show structured plan step list when a current `turn.plan.updated` snapshot exists
5534. Append `plan_text` streaming content beneath or alongside structured steps when both exist
5545. Fall back to assistant text rendering when provider capabilities do not support structure
5556. Show completed selected answers in history after `user-input.resolved`
445Already present:
556446
557### Submission behavior
447- session/update stream handling
448- reasoning/assistant text mapping
449- approval bridging
558450
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
451Missing:
562452
563---
453- plan/default operating mode mapping
454- structured prompt mapping
455- structured plan/todo mapping
564456
565## Data flow end to end
457### Required work
566458
5671. Provider starts session and publishes capabilities + mode
5682. User starts plan-mode turn or provider enters plan mode
5693. Adapter emits structured plan and/or plan text events when available
5704. Adapter emits `user-input.requested` when provider asks a structured question
5715. Frontend renders question card or fallback conversational prompt based on capabilities
5726. User submits answer
5737. Server routes answer back to provider adapter pending request/tool context
5748. Adapter emits `user-input.resolved` and `request.resolved`
5759. Provider may continue planning, emit more plan updates, or emit final plan handoff
57610. Frontend renders final completed plan state and history
4591. Detect and publish Cursor operating mode separately from access-policy runtime mode.
4602. Map documented extension methods such as `cursor/ask_question` and `cursor/update_todos` only when payloads are confirmed.
4613. Fall back to plan-mode indicator plus normal assistant/reasoning text if structured Cursor plan events are absent.
4624. Preserve raw ACP payloads so the adapter can improve without changing higher layers.
577463
578464---
579465
580## Test plan
466## Frontend Rendering Plan
581467
582### Contracts
468The frontend should remain capability-driven, but using real repo seams.
583469
584Files:
470### Active state
585471
586- `packages/contracts/src/providerRuntime.test.ts`
587- related schema tests
472Render from projected snapshot fields:
588473
589Add coverage for:
474- current plan/default operating mode
475- provider capabilities
476- current structured plan
477- current plan-text stream
478- pending structured prompt
590479
591- provider capabilities schema
592- structured user-input prompt schema
593- structured user-input answer schema
594- mode metadata schema
480### History state
595481
596### Server/provider tests
482Render from:
597483
598Files:
484- normal messages
485- resolved prompt answers
486- plan-related activity/history rows
487- optional final-plan artifact
599488
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
604490
605Add coverage for:
491Recommended initial placement:
606492
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
4931. Active pending prompt and active plan panel above the timeline, near the current approvals stack.
4942. Resolved prompt/plan history in the timeline.
614495
615### Web/UI tests
496This fits the existing `ChatView` layout with the least architectural churn.
497
498---
499
500## Test Plan
501
502### Contracts
616503
617504Add coverage for:
618505
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
625510
626### Probe / fixture tests
511Retain existing coverage for:
627512
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`
631517
632---
518### Server
633519
634## Assumptions and defaults
520Add or extend tests for:
635521
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
530Add 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
642539
643540---
644541
645## Recommended execution order
542## Recommended Execution Order
543
5441. Add shared contract types for interaction mode, capabilities, and structured-answer submission.
5452. Extend orchestration read-model contracts and projection storage for active plan/prompt state.
5463. Wire `ProviderRuntimeIngestion` into those projections.
5474. Finish Codex end-to-end first, since it already emits most of the canonical runtime pieces.
5485. Add frontend snapshot sync, derivation, and rendering in `types.ts`, `store.ts`, `session-logic.ts`, and `ChatView.tsx`.
5496. Add Claude adapter mapping.
5507. Add Cursor adapter mapping and fallback behavior.
551
552## Short version
553
554The repo is already close in one specific sense: the runtime event vocabulary for plans and structured prompts exists.
555
556The 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
646563
6471. Formalize canonical capabilities + mode + structured prompt contracts
6482. Wire capabilities/mode through orchestration and web socket projections
6493. Finish Codex end-to-end plan mode implementation first
6504. Implement Claude `AskUserQuestion` and `ExitPlanMode` adapter mapping
6515. Add Cursor binary (`default|plan`) mode support + ACP extension-method mapping
6526. Build capability-driven frontend rendering and answer submission
6537. Add reconnect/history coverage and transcript/protocol fixtures
564That is the shape the implementation should follow.
packages/contracts/src/ipc.ts+7-1
......@@ -14,7 +14,12 @@ import type {
1414 GitStatusInput,
1515 GitStatusResult,
1616} from "./git";
17import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project";
17import type {
18 ProjectSearchEntriesInput,
19 ProjectSearchEntriesResult,
20 ProjectWriteFileInput,
21 ProjectWriteFileResult,
22} from "./project";
1823import type { ServerConfig } from "./server";
1924import type {
2025 TerminalClearInput,
......@@ -104,6 +109,7 @@ export interface NativeApi {
104109 };
105110 projects: {
106111 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
112 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
107113 };
108114 shell: {
109115 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";
22import { Effect, Schema } from "effect";
33
44import {
5 DEFAULT_PROVIDER_INTERACTION_MODE,
56 DEFAULT_RUNTIME_MODE,
67 OrchestrationGetTurnDiffInput,
78 OrchestrationSession,
......@@ -110,6 +111,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
110111 });
111112 assert.strictEqual(parsed.provider, undefined);
112113 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
114 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
113115 }),
114116);
115117
......@@ -131,6 +133,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
131133 });
132134 assert.strictEqual(parsed.provider, "claudeCode");
133135 assert.strictEqual(parsed.runtimeMode, "full-access");
136 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
134137 }),
135138);
136139
......@@ -150,10 +153,11 @@ it.effect("accepts cursor provider in thread.turn.start", () =>
150153 createdAt: "2026-01-01T00:00:00.000Z",
151154 });
152155 assert.strictEqual(parsed.provider, "cursor");
156 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
153157 }),
154158);
155159
156it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () =>
160it.effect("decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () =>
157161 Effect.gen(function* () {
158162 const parsed = yield* decodeThreadTurnStartRequestedPayload({
159163 threadId: "thread-1",
......@@ -162,6 +166,7 @@ it.effect("decodes thread.turn-start-requested defaults for provider and runtime
162166 });
163167 assert.strictEqual(parsed.provider, undefined);
164168 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
169 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
165170 }),
166171);
167172
packages/contracts/src/orchestration.ts+79
......@@ -32,6 +32,9 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3232export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
3333export type RuntimeMode = typeof RuntimeMode.Type;
3434export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
35export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);
36export type ProviderInteractionMode = typeof ProviderInteractionMode.Type;
37export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";
3538export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]);
3639export type ProviderRequestKind = typeof ProviderRequestKind.Type;
3740export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
......@@ -43,6 +46,8 @@ export const ProviderApprovalDecision = Schema.Literals([
4346 "cancel",
4447]);
4548export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
49export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
50export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
4651
4752export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
4853export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
......@@ -219,6 +224,9 @@ export const OrchestrationThread = Schema.Struct({
219224 title: TrimmedNonEmptyString,
220225 model: TrimmedNonEmptyString,
221226 runtimeMode: RuntimeMode,
227 interactionMode: ProviderInteractionMode.pipe(
228 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
229 ),
222230 branch: Schema.NullOr(TrimmedNonEmptyString),
223231 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
224232 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
......@@ -274,6 +282,9 @@ export const ThreadCreateCommand = Schema.Struct({
274282 title: TrimmedNonEmptyString,
275283 model: TrimmedNonEmptyString,
276284 runtimeMode: RuntimeMode,
285 interactionMode: ProviderInteractionMode.pipe(
286 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
287 ),
277288 branch: Schema.NullOr(TrimmedNonEmptyString),
278289 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
279290 createdAt: IsoDateTime,
......@@ -303,6 +314,14 @@ export const ThreadRuntimeModeSetCommand = Schema.Struct({
303314 createdAt: IsoDateTime,
304315});
305316
317export 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
306325export const ThreadTurnStartCommand = Schema.Struct({
307326 type: Schema.Literal("thread.turn.start"),
308327 commandId: CommandId,
......@@ -318,6 +337,9 @@ export const ThreadTurnStartCommand = Schema.Struct({
318337 effort: Schema.optional(TrimmedNonEmptyString),
319338 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
320339 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
340 interactionMode: ProviderInteractionMode.pipe(
341 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
342 ),
321343 createdAt: IsoDateTime,
322344});
323345
......@@ -336,6 +358,7 @@ export const ClientThreadTurnStartCommand = Schema.Struct({
336358 effort: Schema.optional(TrimmedNonEmptyString),
337359 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
338360 runtimeMode: RuntimeMode,
361 interactionMode: ProviderInteractionMode,
339362 createdAt: IsoDateTime,
340363});
341364
......@@ -356,6 +379,15 @@ export const ThreadApprovalRespondCommand = Schema.Struct({
356379 createdAt: IsoDateTime,
357380});
358381
382export 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
359391export const ThreadCheckpointRevertCommand = Schema.Struct({
360392 type: Schema.Literal("thread.checkpoint.revert"),
361393 commandId: CommandId,
......@@ -379,9 +411,11 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([
379411 ThreadDeleteCommand,
380412 ThreadMetaUpdateCommand,
381413 ThreadRuntimeModeSetCommand,
414 ThreadInteractionModeSetCommand,
382415 ThreadTurnStartCommand,
383416 ThreadTurnInterruptCommand,
384417 ThreadApprovalRespondCommand,
418 ThreadUserInputRespondCommand,
385419 ThreadCheckpointRevertCommand,
386420 ThreadSessionStopCommand,
387421]);
......@@ -396,9 +430,11 @@ export const ClientOrchestrationCommand = Schema.Union([
396430 ThreadDeleteCommand,
397431 ThreadMetaUpdateCommand,
398432 ThreadRuntimeModeSetCommand,
433 ThreadInteractionModeSetCommand,
399434 ClientThreadTurnStartCommand,
400435 ThreadTurnInterruptCommand,
401436 ThreadApprovalRespondCommand,
437 ThreadUserInputRespondCommand,
402438 ThreadCheckpointRevertCommand,
403439 ThreadSessionStopCommand,
404440]);
......@@ -485,10 +521,12 @@ export const OrchestrationEventType = Schema.Literals([
485521 "thread.deleted",
486522 "thread.meta-updated",
487523 "thread.runtime-mode-set",
524 "thread.interaction-mode-set",
488525 "thread.message-sent",
489526 "thread.turn-start-requested",
490527 "thread.turn-interrupt-requested",
491528 "thread.approval-response-requested",
529 "thread.user-input-response-requested",
492530 "thread.checkpoint-revert-requested",
493531 "thread.reverted",
494532 "thread.session-stop-requested",
......@@ -532,6 +570,9 @@ export const ThreadCreatedPayload = Schema.Struct({
532570 title: TrimmedNonEmptyString,
533571 model: TrimmedNonEmptyString,
534572 runtimeMode: RuntimeMode,
573 interactionMode: ProviderInteractionMode.pipe(
574 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
575 ),
535576 branch: Schema.NullOr(TrimmedNonEmptyString),
536577 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
537578 createdAt: IsoDateTime,
......@@ -558,6 +599,14 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({
558599 updatedAt: IsoDateTime,
559600});
560601
602export const ThreadInteractionModeSetPayload = Schema.Struct({
603 threadId: ThreadId,
604 interactionMode: ProviderInteractionMode.pipe(
605 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
606 ),
607 updatedAt: IsoDateTime,
608});
609
561610export const ThreadMessageSentPayload = Schema.Struct({
562611 threadId: ThreadId,
563612 messageId: MessageId,
......@@ -578,6 +627,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
578627 effort: Schema.optional(TrimmedNonEmptyString),
579628 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
580629 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
630 interactionMode: ProviderInteractionMode.pipe(
631 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
632 ),
581633 createdAt: IsoDateTime,
582634});
583635
......@@ -594,6 +646,13 @@ export const ThreadApprovalResponseRequestedPayload = Schema.Struct({
594646 createdAt: IsoDateTime,
595647});
596648
649export const ThreadUserInputResponseRequestedPayload = Schema.Struct({
650 threadId: ThreadId,
651 requestId: ApprovalRequestId,
652 answers: ProviderUserInputAnswers,
653 createdAt: IsoDateTime,
654});
655
597656export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({
598657 threadId: ThreadId,
599658 turnCount: NonNegativeInt,
......@@ -702,6 +761,11 @@ export const OrchestrationEvent = Schema.Union([
702761 type: Schema.Literal("thread.runtime-mode-set"),
703762 payload: ThreadRuntimeModeSetPayload,
704763 }),
764 Schema.Struct({
765 ...EventBaseFields,
766 type: Schema.Literal("thread.interaction-mode-set"),
767 payload: ThreadInteractionModeSetPayload,
768 }),
705769 Schema.Struct({
706770 ...EventBaseFields,
707771 type: Schema.Literal("thread.message-sent"),
......@@ -722,6 +786,11 @@ export const OrchestrationEvent = Schema.Union([
722786 type: Schema.Literal("thread.approval-response-requested"),
723787 payload: ThreadApprovalResponseRequestedPayload,
724788 }),
789 Schema.Struct({
790 ...EventBaseFields,
791 type: Schema.Literal("thread.user-input-response-requested"),
792 payload: ThreadUserInputResponseRequestedPayload,
793 }),
725794 Schema.Struct({
726795 ...EventBaseFields,
727796 type: Schema.Literal("thread.checkpoint-revert-requested"),
......@@ -791,6 +860,11 @@ export const OrchestrationPersistedEvent = Schema.Union([
791860 eventType: Schema.Literal("thread.runtime-mode-set"),
792861 payload: ThreadRuntimeModeSetPayload,
793862 }),
863 Schema.Struct({
864 ...PersistedEventBaseFields,
865 eventType: Schema.Literal("thread.interaction-mode-set"),
866 payload: ThreadInteractionModeSetPayload,
867 }),
794868 Schema.Struct({
795869 ...PersistedEventBaseFields,
796870 eventType: Schema.Literal("thread.message-sent"),
......@@ -811,6 +885,11 @@ export const OrchestrationPersistedEvent = Schema.Union([
811885 eventType: Schema.Literal("thread.approval-response-requested"),
812886 payload: ThreadApprovalResponseRequestedPayload,
813887 }),
888 Schema.Struct({
889 ...PersistedEventBaseFields,
890 eventType: Schema.Literal("thread.user-input-response-requested"),
891 payload: ThreadUserInputResponseRequestedPayload,
892 }),
814893 Schema.Struct({
815894 ...PersistedEventBaseFields,
816895 eventType: Schema.Literal("thread.checkpoint-revert-requested"),
packages/contracts/src/project.ts+15
......@@ -2,6 +2,7 @@ import { Schema } from "effect";
22import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
33
44export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
5export const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
56
67export const ProjectSearchEntriesInput = Schema.Struct({
78 cwd: TrimmedNonEmptyString,
......@@ -24,3 +25,17 @@ export const ProjectSearchEntriesResult = Schema.Struct({
2425 truncated: Schema.Boolean,
2526});
2627export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
28
29export 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});
36export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type;
37
38export const ProjectWriteFileResult = Schema.Struct({
39 relativePath: TrimmedNonEmptyString,
40});
41export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
packages/contracts/src/provider.ts+10
......@@ -14,8 +14,10 @@ import {
1414 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1515 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1616 ProviderApprovalDecision,
17 ProviderInteractionMode,
1718 ProviderKind,
1819 ProviderRequestKind,
20 ProviderUserInputAnswers,
1921 RuntimeMode,
2022 TurnCountRange,
2123} from "./orchestration";
......@@ -92,6 +94,7 @@ export const ProviderSendTurnInput = Schema.Struct({
9294 ),
9395 model: Schema.optional(TrimmedNonEmptyStringSchema),
9496 effort: Schema.optional(TrimmedNonEmptyStringSchema),
97 interactionMode: Schema.optional(ProviderInteractionMode),
9598});
9699export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
97100
......@@ -169,6 +172,13 @@ export const ProviderRespondToRequestInput = Schema.Struct({
169172});
170173export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type;
171174
175export const ProviderRespondToUserInputInput = Schema.Struct({
176 threadId: ThreadId,
177 requestId: ApprovalRequestId,
178 answers: ProviderUserInputAnswers,
179});
180export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;
181
172182export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
173183export type ProviderEventKind = typeof ProviderEventKind.Type;
174184
packages/contracts/src/ws.ts+3-1
......@@ -29,7 +29,7 @@ import {
2929 TerminalWriteInput,
3030} from "./terminal";
3131import { KeybindingRule } from "./keybindings";
32import { ProjectSearchEntriesInput } from "./project";
32import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
3333import { OpenInEditorInput } from "./editor";
3434
3535// ── WebSocket RPC Method Names ───────────────────────────────────────
......@@ -40,6 +40,7 @@ export const WS_METHODS = {
4040 projectsAdd: "projects.add",
4141 projectsRemove: "projects.remove",
4242 projectsSearchEntries: "projects.searchEntries",
43 projectsWriteFile: "projects.writeFile",
4344
4445 // Shell methods
4546 shellOpenInEditor: "shell.openInEditor",
......@@ -101,6 +102,7 @@ export const WebSocketRequestBody = Schema.Union([
101102
102103 // Project Search
103104 tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput),
105 tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput),
104106
105107 // Shell methods
106108 tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),