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...@@ -413,6 +413,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
413 })413 })
414 : missingSessionEffect(provider, threadId);414 : 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
416 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>422 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
417 Effect.sync(() => {423 Effect.sync(() => {
418 sessions.delete(threadId);424 sessions.delete(threadId);
...@@ -475,6 +481,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -475,6 +481,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
475 sendTurn,481 sendTurn,
476 interruptTurn,482 interruptTurn,
477 respondToRequest,483 respondToRequest,
484 respondToUserInput,
478 stopSession,485 stopSession,
479 listSessions,486 listSessions,
480 hasSession,487 hasSession,
apps/server/integration/orchestrationEngine.integration.test.ts+6
...@@ -4,6 +4,7 @@ import path from "node:path";...@@ -4,6 +4,7 @@ import path from "node:path";
4import {4import {
5 ApprovalRequestId,5 ApprovalRequestId,
6 CommandId,6 CommandId,
7 DEFAULT_PROVIDER_INTERACTION_MODE,
7 EventId,8 EventId,
8 MessageId,9 MessageId,
9 ProjectId,10 ProjectId,
...@@ -118,6 +119,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>...@@ -118,6 +119,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118 projectId: PROJECT_ID,119 projectId: PROJECT_ID,
119 title: "Integration Thread",120 title: "Integration Thread",
120 model: "gpt-5-codex",121 model: "gpt-5-codex",
122 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
121 runtimeMode: "approval-required",123 runtimeMode: "approval-required",
122 branch: null,124 branch: null,
123 worktreePath: harness.workspaceDir,125 worktreePath: harness.workspaceDir,
...@@ -143,6 +145,7 @@ const startTurn = (input: {...@@ -143,6 +145,7 @@ const startTurn = (input: {
143 attachments: [],145 attachments: [],
144 },146 },
145 ...(input.provider !== undefined ? { provider: input.provider } : {}),147 ...(input.provider !== undefined ? { provider: input.provider } : {}),
148 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
146 runtimeMode: "approval-required",149 runtimeMode: "approval-required",
147 createdAt: nowIso(),150 createdAt: nowIso(),
148 });151 });
...@@ -241,6 +244,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -241,6 +244,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
241 projectId: PROJECT_ID,244 projectId: PROJECT_ID,
242 title: "Integration Thread",245 title: "Integration Thread",
243 model: "gpt-5.3-codex",246 model: "gpt-5.3-codex",
247 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
244 runtimeMode: "full-access",248 runtimeMode: "full-access",
245 branch: null,249 branch: null,
246 worktreePath: harness.workspaceDir,250 worktreePath: harness.workspaceDir,
...@@ -257,6 +261,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -257,6 +261,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
257 text: "Reply with exactly ALPHA.",261 text: "Reply with exactly ALPHA.",
258 attachments: [],262 attachments: [],
259 },263 },
264 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
260 runtimeMode: "full-access",265 runtimeMode: "full-access",
261 createdAt: nowIso(),266 createdAt: nowIso(),
262 });267 });
...@@ -283,6 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -283,6 +288,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
283 text: "Reply with exactly BETA.",288 text: "Reply with exactly BETA.",
284 attachments: [],289 attachments: [],
285 },290 },
291 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
286 runtimeMode: "approval-required",292 runtimeMode: "approval-required",
287 createdAt: nowIso(),293 createdAt: nowIso(),
288 });294 });
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 CheckpointRef,2 CheckpointRef,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
3 ProjectId,4 ProjectId,
4 ThreadId,5 ThreadId,
5 TurnId,6 TurnId,
...@@ -43,6 +44,7 @@ function makeSnapshot(input: {...@@ -43,6 +44,7 @@ function makeSnapshot(input: {
43 projectId: input.projectId,44 projectId: input.projectId,
44 title: "Thread",45 title: "Thread",
45 model: "gpt-5-codex",46 model: "gpt-5-codex",
47 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
46 runtimeMode: "full-access",48 runtimeMode: "full-access",
47 branch: null,49 branch: null,
48 worktreePath: input.worktreePath,50 worktreePath: input.worktreePath,
apps/server/src/codexAppServerManager.test.ts+129-1
...@@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto";...@@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto";
3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4import os from "node:os";4import os from "node:os";
5import path from "node:path";5import path from "node:path";
6import { ThreadId } from "@t3tools/contracts";6import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
77
8import {8import {
9 buildCodexInitializeParams,
10 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
9 CodexAppServerManager,11 CodexAppServerManager,
10 classifyCodexStderrLine,12 classifyCodexStderrLine,
11 isRecoverableThreadResumeError,13 isRecoverableThreadResumeError,
...@@ -22,6 +24,7 @@ function createSendTurnHarness() {...@@ -22,6 +24,7 @@ function createSendTurnHarness() {
22 provider: "codex",24 provider: "codex",
23 status: "ready",25 status: "ready",
24 threadId: "thread_1",26 threadId: "thread_1",
27 resumeCursor: { threadId: "thread_1" },
25 createdAt: "2026-02-10T00:00:00.000Z",28 createdAt: "2026-02-10T00:00:00.000Z",
26 updatedAt: "2026-02-10T00:00:00.000Z",29 updatedAt: "2026-02-10T00:00:00.000Z",
27 },30 },
...@@ -58,6 +61,7 @@ function createThreadControlHarness() {...@@ -58,6 +61,7 @@ function createThreadControlHarness() {
58 provider: "codex",61 provider: "codex",
59 status: "ready",62 status: "ready",
60 threadId: "thread_1",63 threadId: "thread_1",
64 resumeCursor: { threadId: "thread_1" },
61 createdAt: "2026-02-10T00:00:00.000Z",65 createdAt: "2026-02-10T00:00:00.000Z",
62 updatedAt: "2026-02-10T00:00:00.000Z",66 updatedAt: "2026-02-10T00:00:00.000Z",
63 },67 },
...@@ -80,6 +84,46 @@ function createThreadControlHarness() {...@@ -80,6 +84,46 @@ function createThreadControlHarness() {
80 return { manager, context, requireSession, sendRequest, updateSession };84 return { manager, context, requireSession, sendRequest, updateSession };
81}85}
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
83describe("classifyCodexStderrLine", () => {127describe("classifyCodexStderrLine", () => {
84 it("ignores empty lines", () => {128 it("ignores empty lines", () => {
85 expect(classifyCodexStderrLine(" ")).toBeNull();129 expect(classifyCodexStderrLine(" ")).toBeNull();
...@@ -151,6 +195,19 @@ describe("isRecoverableThreadResumeError", () => {...@@ -151,6 +195,19 @@ describe("isRecoverableThreadResumeError", () => {
151});195});
152196
153describe("startSession", () => {197describe("startSession", () => {
198 it("enables Codex experimental api capabilities during initialize", () => {
199 expect(buildCodexInitializeParams()).toEqual({
200 clientInfo: {
201 name: "t3code_desktop",
202 title: "T3 Code Desktop",
203 version: "0.1.0",
204 },
205 capabilities: {
206 experimentalApi: true,
207 },
208 });
209 });
210
154 it("emits session/startFailed when resolving cwd throws before process launch", async () => {211 it("emits session/startFailed when resolving cwd throws before process launch", async () => {
155 const manager = new CodexAppServerManager();212 const manager = new CodexAppServerManager();
156 const events: Array<{ method: string; kind: string; message?: string }> = [];213 const events: Array<{ method: string; kind: string; message?: string }> = [];
...@@ -207,6 +264,7 @@ describe("sendTurn", () => {...@@ -207,6 +264,7 @@ describe("sendTurn", () => {
207 expect(result).toEqual({264 expect(result).toEqual({
208 threadId: "thread_1",265 threadId: "thread_1",
209 turnId: "turn_1",266 turnId: "turn_1",
267 resumeCursor: { threadId: "thread_1" },
210 });268 });
211 expect(requireSession).toHaveBeenCalledWith("thread_1");269 expect(requireSession).toHaveBeenCalledWith("thread_1");
212 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {270 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
...@@ -228,6 +286,7 @@ describe("sendTurn", () => {...@@ -228,6 +286,7 @@ describe("sendTurn", () => {
228 expect(updateSession).toHaveBeenCalledWith(context, {286 expect(updateSession).toHaveBeenCalledWith(context, {
229 status: "running",287 status: "running",
230 activeTurnId: "turn_1",288 activeTurnId: "turn_1",
289 resumeCursor: { threadId: "thread_1" },
231 });290 });
232 });291 });
233292
...@@ -255,6 +314,36 @@ describe("sendTurn", () => {...@@ -255,6 +314,36 @@ describe("sendTurn", () => {
255 });314 });
256 });315 });
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
258 it("rejects empty turn input", async () => {347 it("rejects empty turn input", async () => {
259 const { manager } = createSendTurnHarness();348 const { manager } = createSendTurnHarness();
260349
...@@ -354,6 +443,45 @@ describe("thread checkpoint control", () => {...@@ -354,6 +443,45 @@ describe("thread checkpoint control", () => {
354 });443 });
355});444});
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
357describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {485describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
358 it(486 it(
359 "keeps prior thread history when resuming with a changed runtime mode",487 "keeps prior thread history when resuming with a changed runtime mode",
apps/server/src/codexAppServerManager.ts+281-15
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 EventId,8 EventId,
9 ProviderItemId,9 ProviderItemId,
10 ProviderRequestKind,10 ProviderRequestKind,
11 type ProviderUserInputAnswers,
11 ThreadId,12 ThreadId,
12 TurnId,13 TurnId,
13 normalizeModelSlug,14 normalizeModelSlug,
...@@ -38,12 +39,25 @@ interface PendingApprovalRequest {...@@ -38,12 +39,25 @@ interface PendingApprovalRequest {
38 itemId?: ProviderItemId;39 itemId?: ProviderItemId;
39}40}
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
41interface CodexSessionContext {54interface CodexSessionContext {
42 session: ProviderSession;55 session: ProviderSession;
43 child: ChildProcessWithoutNullStreams;56 child: ChildProcessWithoutNullStreams;
44 output: readline.Interface;57 output: readline.Interface;
45 pending: Map<PendingRequestKey, PendingRequest>;58 pending: Map<PendingRequestKey, PendingRequest>;
46 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;59 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;
60 pendingUserInputs: Map<ApprovalRequestId, PendingUserInputRequest>;
47 nextRequestId: number;61 nextRequestId: number;
48 stopping: boolean;62 stopping: boolean;
49}63}
...@@ -76,6 +90,7 @@ export interface CodexAppServerSendTurnInput {...@@ -76,6 +90,7 @@ export interface CodexAppServerSendTurnInput {
76 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;90 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
77 readonly model?: string;91 readonly model?: string;
78 readonly effort?: string;92 readonly effort?: string;
93 readonly interactionMode?: "default" | "plan";
79}94}
8095
81export interface CodexThreadTurnSnapshot {96export interface CodexThreadTurnSnapshot {
...@@ -103,6 +118,127 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [...@@ -103,6 +118,127 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
103 "unknown thread",118 "unknown thread",
104 "does not exist",119 "does not exist",
105];120];
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
107function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): {243function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): {
108 readonly approvalPolicy: "on-request" | "never";244 readonly approvalPolicy: "on-request" | "never";
...@@ -154,6 +290,80 @@ export function normalizeCodexModelSlug(...@@ -154,6 +290,80 @@ export function normalizeCodexModelSlug(
154 return normalized;290 return normalized;
155}291}
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
157export function classifyCodexStderrLine(rawLine: string): { message: string } | null {367export function classifyCodexStderrLine(rawLine: string): { message: string } | null {
158 const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim();368 const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim();
159 if (!line) {369 if (!line) {
...@@ -237,6 +447,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -237,6 +447,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
237 output,447 output,
238 pending: new Map(),448 pending: new Map(),
239 pendingApprovals: new Map(),449 pendingApprovals: new Map(),
450 pendingUserInputs: new Map(),
240 nextRequestId: 1,451 nextRequestId: 1,
241 stopping: false,452 stopping: false,
242 };453 };
...@@ -246,16 +457,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -246,16 +457,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
246457
247 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");458 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");
248459
249 await this.sendRequest(context, "initialize", {460 await this.sendRequest(context, "initialize", buildCodexInitializeParams());
250 clientInfo: {
251 name: "t3code_desktop",
252 title: "T3 Code Desktop",
253 version: "0.1.0",
254 },
255 capabilities: {
256 experimentalApi: false,
257 },
258 });
259461
260 this.writeMessage(context, { method: "initialized" });462 this.writeMessage(context, { method: "initialized" });
261463
...@@ -423,17 +625,36 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -423,17 +625,36 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
423 >;625 >;
424 model?: string;626 model?: string;
425 effort?: string;627 effort?: string;
628 collaborationMode?: {
629 mode: "plan";
630 settings: {
631 model: string;
632 reasoning_effort: string;
633 developer_instructions: string;
634 };
635 };
426 } = {636 } = {
427 threadId: providerThreadId,637 threadId: providerThreadId,
428 input: turnInput,638 input: turnInput,
429 };639 };
430 const normalizedModel = normalizeCodexModelSlug(input.model);640 const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model);
431 if (normalizedModel) {641 if (normalizedModel) {
432 turnStartParams.model = normalizedModel;642 turnStartParams.model = normalizedModel;
433 }643 }
434 if (input.effort) {644 if (input.effort) {
435 turnStartParams.effort = input.effort;645 turnStartParams.effort = input.effort;
436 }646 }
647 const collaborationMode = buildCodexCollaborationMode({
648 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
649 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
650 ...(input.effort !== undefined ? { effort: input.effort } : {}),
651 });
652 if (collaborationMode) {
653 if (!turnStartParams.model) {
654 turnStartParams.model = collaborationMode.settings.model;
655 }
656 turnStartParams.collaborationMode = collaborationMode;
657 }
437658
438 const response = await this.sendRequest(context, "turn/start", turnStartParams);659 const response = await this.sendRequest(context, "turn/start", turnStartParams);
439660
...@@ -561,6 +782,43 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -561,6 +782,43 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
561 });782 });
562 }783 }
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
564 stopSession(threadId: ThreadId): void {822 stopSession(threadId: ThreadId): void {
565 const context = this.sessions.get(threadId);823 const context = this.sessions.get(threadId);
566 if (!context) {824 if (!context) {
...@@ -575,6 +833,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -575,6 +833,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
575 }833 }
576 context.pending.clear();834 context.pending.clear();
577 context.pendingApprovals.clear();835 context.pendingApprovals.clear();
836 context.pendingUserInputs.clear();
578837
579 context.output.close();838 context.output.close();
580839
...@@ -792,6 +1051,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -792,6 +1051,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
792 context.pendingApprovals.set(requestId, pendingRequest);1051 context.pendingApprovals.set(requestId, pendingRequest);
793 }1052 }
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
795 this.emitEvent({1065 this.emitEvent({
796 id: EventId.makeUnsafe(randomUUID()),1066 id: EventId.makeUnsafe(randomUUID()),
797 kind: "request",1067 kind: "request",
...@@ -811,10 +1081,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -811,10 +1081,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
811 }1081 }
8121082
813 if (request.method === "item/tool/requestUserInput") {1083 if (request.method === "item/tool/requestUserInput") {
814 this.writeMessage(context, {
815 id: request.id,
816 result: { answers: {} },
817 });
818 return;1084 return;
819 }1085 }
8201086
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+4
...@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";...@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
7import {7import {
8 CommandId,8 CommandId,
9 DEFAULT_PROVIDER_INTERACTION_MODE,
9 EventId,10 EventId,
10 MessageId,11 MessageId,
11 ProjectId,12 ProjectId,
...@@ -85,6 +86,7 @@ function createProviderServiceHarness(...@@ -85,6 +86,7 @@ function createProviderServiceHarness(
85 sendTurn: () => unsupported(),86 sendTurn: () => unsupported(),
86 interruptTurn: () => unsupported(),87 interruptTurn: () => unsupported(),
87 respondToRequest: () => unsupported(),88 respondToRequest: () => unsupported(),
89 respondToUserInput: () => unsupported(),
88 stopSession: () => unsupported(),90 stopSession: () => unsupported(),
89 listSessions,91 listSessions,
90 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),92 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
...@@ -286,6 +288,7 @@ describe("CheckpointReactor", () => {...@@ -286,6 +288,7 @@ describe("CheckpointReactor", () => {
286 projectId: asProjectId("project-1"),288 projectId: asProjectId("project-1"),
287 title: "Thread",289 title: "Thread",
288 model: "gpt-5-codex",290 model: "gpt-5-codex",
291 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
289 runtimeMode: "approval-required",292 runtimeMode: "approval-required",
290 branch: null,293 branch: null,
291 worktreePath: options?.threadWorktreePath ?? cwd,294 worktreePath: options?.threadWorktreePath ?? cwd,
...@@ -601,6 +604,7 @@ describe("CheckpointReactor", () => {...@@ -601,6 +604,7 @@ describe("CheckpointReactor", () => {
601 text: "start turn",604 text: "start turn",
602 attachments: [],605 attachments: [],
603 },606 },
607 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
604 runtimeMode: "approval-required",608 runtimeMode: "approval-required",
605 createdAt: new Date().toISOString(),609 createdAt: new Date().toISOString(),
606 }),610 }),
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+17-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 CheckpointRef,2 CheckpointRef,
3 CommandId,3 CommandId,
4 DEFAULT_PROVIDER_INTERACTION_MODE,
4 MessageId,5 MessageId,
5 ProjectId,6 ProjectId,
6 ThreadId,7 ThreadId,
...@@ -80,6 +81,7 @@ describe("OrchestrationEngine", () => {...@@ -80,6 +81,7 @@ describe("OrchestrationEngine", () => {
80 projectId: asProjectId("project-1"),81 projectId: asProjectId("project-1"),
81 title: "Thread",82 title: "Thread",
82 model: "gpt-5-codex",83 model: "gpt-5-codex",
84 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
83 runtimeMode: "approval-required",85 runtimeMode: "approval-required",
84 branch: null,86 branch: null,
85 worktreePath: null,87 worktreePath: null,
...@@ -97,6 +99,7 @@ describe("OrchestrationEngine", () => {...@@ -97,6 +99,7 @@ describe("OrchestrationEngine", () => {
97 text: "hello",99 text: "hello",
98 attachments: [],100 attachments: [],
99 },101 },
102 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
100 runtimeMode: "approval-required",103 runtimeMode: "approval-required",
101 createdAt,104 createdAt,
102 }),105 }),
...@@ -132,6 +135,7 @@ describe("OrchestrationEngine", () => {...@@ -132,6 +135,7 @@ describe("OrchestrationEngine", () => {
132 projectId: asProjectId("project-replay"),135 projectId: asProjectId("project-replay"),
133 title: "replay",136 title: "replay",
134 model: "gpt-5-codex",137 model: "gpt-5-codex",
138 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
135 runtimeMode: "approval-required",139 runtimeMode: "approval-required",
136 branch: null,140 branch: null,
137 worktreePath: null,141 worktreePath: null,
...@@ -193,7 +197,8 @@ describe("OrchestrationEngine", () => {...@@ -193,7 +197,8 @@ describe("OrchestrationEngine", () => {
193 projectId: asProjectId("project-stream"),197 projectId: asProjectId("project-stream"),
194 title: "domain-stream",198 title: "domain-stream",
195 model: "gpt-5-codex",199 model: "gpt-5-codex",
196 runtimeMode: "approval-required",200 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
201 runtimeMode: "approval-required",
197 branch: null,202 branch: null,
198 worktreePath: null,203 worktreePath: null,
199 createdAt,204 createdAt,
...@@ -237,6 +242,7 @@ describe("OrchestrationEngine", () => {...@@ -237,6 +242,7 @@ describe("OrchestrationEngine", () => {
237 projectId: asProjectId("project-turn-diff"),242 projectId: asProjectId("project-turn-diff"),
238 title: "Turn diff thread",243 title: "Turn diff thread",
239 model: "gpt-5-codex",244 model: "gpt-5-codex",
245 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
240 runtimeMode: "approval-required",246 runtimeMode: "approval-required",
241 branch: null,247 branch: null,
242 worktreePath: null,248 worktreePath: null,
...@@ -345,7 +351,8 @@ describe("OrchestrationEngine", () => {...@@ -345,7 +351,8 @@ describe("OrchestrationEngine", () => {
345 projectId: asProjectId("project-flaky"),351 projectId: asProjectId("project-flaky"),
346 title: "flaky-fail",352 title: "flaky-fail",
347 model: "gpt-5-codex",353 model: "gpt-5-codex",
348 runtimeMode: "approval-required",354 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
355 runtimeMode: "approval-required",
349 branch: null,356 branch: null,
350 worktreePath: null,357 worktreePath: null,
351 createdAt,358 createdAt,
...@@ -361,6 +368,7 @@ describe("OrchestrationEngine", () => {...@@ -361,6 +368,7 @@ describe("OrchestrationEngine", () => {
361 projectId: asProjectId("project-flaky"),368 projectId: asProjectId("project-flaky"),
362 title: "flaky-ok",369 title: "flaky-ok",
363 model: "gpt-5-codex",370 model: "gpt-5-codex",
371 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
364 runtimeMode: "approval-required",372 runtimeMode: "approval-required",
365 branch: null,373 branch: null,
366 worktreePath: null,374 worktreePath: null,
...@@ -425,6 +433,7 @@ describe("OrchestrationEngine", () => {...@@ -425,6 +433,7 @@ describe("OrchestrationEngine", () => {
425 projectId: asProjectId("project-atomic"),433 projectId: asProjectId("project-atomic"),
426 title: "atomic",434 title: "atomic",
427 model: "gpt-5-codex",435 model: "gpt-5-codex",
436 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
428 runtimeMode: "approval-required",437 runtimeMode: "approval-required",
429 branch: null,438 branch: null,
430 worktreePath: null,439 worktreePath: null,
...@@ -442,6 +451,7 @@ describe("OrchestrationEngine", () => {...@@ -442,6 +451,7 @@ describe("OrchestrationEngine", () => {
442 text: "hello",451 text: "hello",
443 attachments: [],452 attachments: [],
444 },453 },
454 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
445 runtimeMode: "approval-required" as const,455 runtimeMode: "approval-required" as const,
446 createdAt,456 createdAt,
447 };457 };
...@@ -558,6 +568,7 @@ describe("OrchestrationEngine", () => {...@@ -558,6 +568,7 @@ describe("OrchestrationEngine", () => {
558 projectId: asProjectId("project-sync"),568 projectId: asProjectId("project-sync"),
559 title: "sync-before",569 title: "sync-before",
560 model: "gpt-5-codex",570 model: "gpt-5-codex",
571 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
561 runtimeMode: "approval-required",572 runtimeMode: "approval-required",
562 branch: null,573 branch: null,
563 worktreePath: null,574 worktreePath: null,
...@@ -602,6 +613,7 @@ describe("OrchestrationEngine", () => {...@@ -602,6 +613,7 @@ describe("OrchestrationEngine", () => {
602 text: "hello",613 text: "hello",
603 attachments: [],614 attachments: [],
604 },615 },
616 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
605 runtimeMode: "approval-required",617 runtimeMode: "approval-required",
606 createdAt: now(),618 createdAt: now(),
607 }),619 }),
...@@ -636,6 +648,7 @@ describe("OrchestrationEngine", () => {...@@ -636,6 +648,7 @@ describe("OrchestrationEngine", () => {
636 projectId: asProjectId("project-duplicate"),648 projectId: asProjectId("project-duplicate"),
637 title: "duplicate",649 title: "duplicate",
638 model: "gpt-5-codex",650 model: "gpt-5-codex",
651 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
639 runtimeMode: "approval-required",652 runtimeMode: "approval-required",
640 branch: null,653 branch: null,
641 worktreePath: null,654 worktreePath: null,
...@@ -652,7 +665,8 @@ describe("OrchestrationEngine", () => {...@@ -652,7 +665,8 @@ describe("OrchestrationEngine", () => {
652 projectId: asProjectId("project-duplicate"),665 projectId: asProjectId("project-duplicate"),
653 title: "duplicate",666 title: "duplicate",
654 model: "gpt-5-codex",667 model: "gpt-5-codex",
655 runtimeMode: "approval-required",668 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
669 runtimeMode: "approval-required",
656 branch: null,670 branch: null,
657 worktreePath: null,671 worktreePath: null,
658 createdAt,672 createdAt,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16
...@@ -397,6 +397,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -397,6 +397,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
397 title: event.payload.title,397 title: event.payload.title,
398 model: event.payload.model,398 model: event.payload.model,
399 runtimeMode: event.payload.runtimeMode,399 runtimeMode: event.payload.runtimeMode,
400 interactionMode: event.payload.interactionMode,
400 branch: event.payload.branch,401 branch: event.payload.branch,
401 worktreePath: event.payload.worktreePath,402 worktreePath: event.payload.worktreePath,
402 latestTurnId: null,403 latestTurnId: null,
...@@ -441,6 +442,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -441,6 +442,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
441 return;442 return;
442 }443 }
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
444 case "thread.deleted": {460 case "thread.deleted": {
445 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);461 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
446 const existingRow = yield* projectionThreadRepository.getById({462 const existingRow = yield* projectionThreadRepository.getById({
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
...@@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
234 projectId: asProjectId("project-1"),234 projectId: asProjectId("project-1"),
235 title: "Thread 1",235 title: "Thread 1",
236 model: "gpt-5-codex",236 model: "gpt-5-codex",
237 interactionMode: "default",
237 runtimeMode: "full-access",238 runtimeMode: "full-access",
238 branch: null,239 branch: null,
239 worktreePath: null,240 worktreePath: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2
...@@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -154,6 +154,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
154 title,154 title,
155 model,155 model,
156 runtime_mode AS "runtimeMode",156 runtime_mode AS "runtimeMode",
157 interaction_mode AS "interactionMode",
157 branch,158 branch,
158 worktree_path AS "worktreePath",159 worktree_path AS "worktreePath",
159 latest_turn_id AS "latestTurnId",160 latest_turn_id AS "latestTurnId",
...@@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
485 title: row.title,486 title: row.title,
486 model: row.model,487 model: row.model,
487 runtimeMode: row.runtimeMode,488 runtimeMode: row.runtimeMode,
489 interactionMode: row.interactionMode,
488 branch: row.branch,490 branch: row.branch,
489 worktreePath: row.worktreePath,491 worktreePath: row.worktreePath,
490 latestTurn: latestTurnByThread.get(row.threadId) ?? null,492 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+102-1
...@@ -6,6 +6,7 @@ import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";...@@ -6,6 +6,7 @@ import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
6import {6import {
7 ApprovalRequestId,7 ApprovalRequestId,
8 CommandId,8 CommandId,
9 DEFAULT_PROVIDER_INTERACTION_MODE,
9 EventId,10 EventId,
10 MessageId,11 MessageId,
11 ProjectId,12 ProjectId,
...@@ -139,6 +140,7 @@ describe("ProviderCommandReactor", () => {...@@ -139,6 +140,7 @@ describe("ProviderCommandReactor", () => {
139 );140 );
140 const interruptTurn = vi.fn((_: unknown) => Effect.void);141 const interruptTurn = vi.fn((_: unknown) => Effect.void);
141 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);142 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
143 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
142 const stopSession = vi.fn((input: unknown) =>144 const stopSession = vi.fn((input: unknown) =>
143 Effect.sync(() => {145 Effect.sync(() => {
144 const threadId =146 const threadId =
...@@ -180,6 +182,7 @@ describe("ProviderCommandReactor", () => {...@@ -180,6 +182,7 @@ describe("ProviderCommandReactor", () => {
180 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],182 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
181 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],183 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
182 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],184 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
185 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
183 stopSession: stopSession as ProviderServiceShape["stopSession"],186 stopSession: stopSession as ProviderServiceShape["stopSession"],
184 listSessions: () => Effect.succeed(runtimeSessions),187 listSessions: () => Effect.succeed(runtimeSessions),
185 getCapabilities: (provider) =>188 getCapabilities: (provider) =>
...@@ -234,6 +237,7 @@ describe("ProviderCommandReactor", () => {...@@ -234,6 +237,7 @@ describe("ProviderCommandReactor", () => {
234 projectId: asProjectId("project-1"),237 projectId: asProjectId("project-1"),
235 title: "Thread",238 title: "Thread",
236 model: "gpt-5-codex",239 model: "gpt-5-codex",
240 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
237 runtimeMode: "approval-required",241 runtimeMode: "approval-required",
238 branch: null,242 branch: null,
239 worktreePath: null,243 worktreePath: null,
...@@ -247,6 +251,7 @@ describe("ProviderCommandReactor", () => {...@@ -247,6 +251,7 @@ describe("ProviderCommandReactor", () => {
247 sendTurn,251 sendTurn,
248 interruptTurn,252 interruptTurn,
249 respondToRequest,253 respondToRequest,
254 respondToUserInput,
250 stopSession,255 stopSession,
251 renameBranch,256 renameBranch,
252 generateBranchName,257 generateBranchName,
...@@ -269,6 +274,7 @@ describe("ProviderCommandReactor", () => {...@@ -269,6 +274,7 @@ describe("ProviderCommandReactor", () => {
269 text: "hello reactor",274 text: "hello reactor",
270 attachments: [],275 attachments: [],
271 },276 },
277 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
272 runtimeMode: "approval-required",278 runtimeMode: "approval-required",
273 createdAt: now,279 createdAt: now,
274 }),280 }),
...@@ -289,6 +295,44 @@ describe("ProviderCommandReactor", () => {...@@ -289,6 +295,44 @@ describe("ProviderCommandReactor", () => {
289 expect(thread?.session?.runtimeMode).toBe("approval-required");295 expect(thread?.session?.runtimeMode).toBe("approval-required");
290 });296 });
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
292 it("starts first turn with requested provider when provider is specified", async () => {336 it("starts first turn with requested provider when provider is specified", async () => {
293 const harness = await createHarness();337 const harness = await createHarness();
294 const now = new Date().toISOString();338 const now = new Date().toISOString();
...@@ -305,6 +349,7 @@ describe("ProviderCommandReactor", () => {...@@ -305,6 +349,7 @@ describe("ProviderCommandReactor", () => {
305 attachments: [],349 attachments: [],
306 },350 },
307 provider: "claudeCode",351 provider: "claudeCode",
352 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
308 runtimeMode: "approval-required",353 runtimeMode: "approval-required",
309 createdAt: now,354 createdAt: now,
310 }),355 }),
...@@ -341,6 +386,7 @@ describe("ProviderCommandReactor", () => {...@@ -341,6 +386,7 @@ describe("ProviderCommandReactor", () => {
341 attachments: [],386 attachments: [],
342 },387 },
343 provider: "cursor",388 provider: "cursor",
389 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
344 runtimeMode: "approval-required",390 runtimeMode: "approval-required",
345 createdAt: now,391 createdAt: now,
346 }),392 }),
...@@ -376,6 +422,7 @@ describe("ProviderCommandReactor", () => {...@@ -376,6 +422,7 @@ describe("ProviderCommandReactor", () => {
376 text: "first",422 text: "first",
377 attachments: [],423 attachments: [],
378 },424 },
425 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
379 runtimeMode: "approval-required",426 runtimeMode: "approval-required",
380 createdAt: now,427 createdAt: now,
381 }),428 }),
...@@ -395,6 +442,7 @@ describe("ProviderCommandReactor", () => {...@@ -395,6 +442,7 @@ describe("ProviderCommandReactor", () => {
395 text: "second",442 text: "second",
396 attachments: [],443 attachments: [],
397 },444 },
445 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
398 runtimeMode: "approval-required",446 runtimeMode: "approval-required",
399 createdAt: now,447 createdAt: now,
400 }),448 }),
...@@ -422,6 +470,7 @@ describe("ProviderCommandReactor", () => {...@@ -422,6 +470,7 @@ describe("ProviderCommandReactor", () => {
422 },470 },
423 provider: "cursor",471 provider: "cursor",
424 model: "composer-1.5",472 model: "composer-1.5",
473 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
425 runtimeMode: "approval-required",474 runtimeMode: "approval-required",
426 createdAt: now,475 createdAt: now,
427 }),476 }),
...@@ -443,6 +492,7 @@ describe("ProviderCommandReactor", () => {...@@ -443,6 +492,7 @@ describe("ProviderCommandReactor", () => {
443 },492 },
444 provider: "cursor",493 provider: "cursor",
445 model: "composer-1.5",494 model: "composer-1.5",
495 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
446 runtimeMode: "approval-required",496 runtimeMode: "approval-required",
447 createdAt: now,497 createdAt: now,
448 }),498 }),
...@@ -470,6 +520,7 @@ describe("ProviderCommandReactor", () => {...@@ -470,6 +520,7 @@ describe("ProviderCommandReactor", () => {
470 },520 },
471 provider: "cursor",521 provider: "cursor",
472 model: "gpt-5.3-codex",522 model: "gpt-5.3-codex",
523 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
473 runtimeMode: "approval-required",524 runtimeMode: "approval-required",
474 createdAt: now,525 createdAt: now,
475 }),526 }),
...@@ -491,6 +542,7 @@ describe("ProviderCommandReactor", () => {...@@ -491,6 +542,7 @@ describe("ProviderCommandReactor", () => {
491 },542 },
492 provider: "cursor",543 provider: "cursor",
493 model: "composer-1.5",544 model: "composer-1.5",
545 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
494 runtimeMode: "approval-required",546 runtimeMode: "approval-required",
495 createdAt: now,547 createdAt: now,
496 }),548 }),
...@@ -531,6 +583,7 @@ describe("ProviderCommandReactor", () => {...@@ -531,6 +583,7 @@ describe("ProviderCommandReactor", () => {
531 text: "first",583 text: "first",
532 attachments: [],584 attachments: [],
533 },585 },
586 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
534 runtimeMode: "full-access",587 runtimeMode: "full-access",
535 createdAt: now,588 createdAt: now,
536 }),589 }),
...@@ -566,6 +619,7 @@ describe("ProviderCommandReactor", () => {...@@ -566,6 +619,7 @@ describe("ProviderCommandReactor", () => {
566 text: "second",619 text: "second",
567 attachments: [],620 attachments: [],
568 },621 },
622 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
569 runtimeMode: "full-access",623 runtimeMode: "full-access",
570 createdAt: now,624 createdAt: now,
571 }),625 }),
...@@ -604,6 +658,7 @@ describe("ProviderCommandReactor", () => {...@@ -604,6 +658,7 @@ describe("ProviderCommandReactor", () => {
604 text: "first",658 text: "first",
605 attachments: [],659 attachments: [],
606 },660 },
661 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
607 runtimeMode: "approval-required",662 runtimeMode: "approval-required",
608 createdAt: now,663 createdAt: now,
609 }),664 }),
...@@ -624,6 +679,7 @@ describe("ProviderCommandReactor", () => {...@@ -624,6 +679,7 @@ describe("ProviderCommandReactor", () => {
624 attachments: [],679 attachments: [],
625 },680 },
626 provider: "claudeCode",681 provider: "claudeCode",
682 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
627 runtimeMode: "approval-required",683 runtimeMode: "approval-required",
628 createdAt: now,684 createdAt: now,
629 }),685 }),
...@@ -672,6 +728,7 @@ describe("ProviderCommandReactor", () => {...@@ -672,6 +728,7 @@ describe("ProviderCommandReactor", () => {
672 text: "first",728 text: "first",
673 attachments: [],729 attachments: [],
674 },730 },
731 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
675 runtimeMode: "full-access",732 runtimeMode: "full-access",
676 createdAt: now,733 createdAt: now,
677 }),734 }),
...@@ -746,7 +803,6 @@ describe("ProviderCommandReactor", () => {...@@ -746,7 +803,6 @@ describe("ProviderCommandReactor", () => {
746 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);803 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);
747 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({804 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({
748 threadId: "thread-1",805 threadId: "thread-1",
749 turnId: "turn-1",
750 });806 });
751 });807 });
752808
...@@ -791,6 +847,51 @@ describe("ProviderCommandReactor", () => {...@@ -791,6 +847,51 @@ describe("ProviderCommandReactor", () => {
791 });847 });
792 });848 });
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
794 it("surfaces stale provider approval request failures without faking approval resolution", async () => {895 it("surfaces stale provider approval request failures without faking approval resolution", async () => {
795 const harness = await createHarness();896 const harness = await createHarness();
796 const now = new Date().toISOString();897 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+53
...@@ -31,6 +31,7 @@ type ProviderIntentEvent = Extract<...@@ -31,6 +31,7 @@ type ProviderIntentEvent = Extract<
31 | "thread.turn-start-requested"31 | "thread.turn-start-requested"
32 | "thread.turn-interrupt-requested"32 | "thread.turn-interrupt-requested"
33 | "thread.approval-response-requested"33 | "thread.approval-response-requested"
34 | "thread.user-input-response-requested"
34 | "thread.session-stop-requested";35 | "thread.session-stop-requested";
35 }36 }
36>;37>;
...@@ -147,6 +148,7 @@ const make = Effect.gen(function* () {...@@ -147,6 +148,7 @@ const make = Effect.gen(function* () {
147 | "provider.turn.start.failed"148 | "provider.turn.start.failed"
148 | "provider.turn.interrupt.failed"149 | "provider.turn.interrupt.failed"
149 | "provider.approval.respond.failed"150 | "provider.approval.respond.failed"
151 | "provider.user-input.respond.failed"
150 | "provider.session.stop.failed";152 | "provider.session.stop.failed";
151 readonly summary: string;153 readonly summary: string;
152 readonly detail: string;154 readonly detail: string;
...@@ -320,6 +322,7 @@ const make = Effect.gen(function* () {...@@ -320,6 +322,7 @@ const make = Effect.gen(function* () {
320 readonly provider?: ProviderKind;322 readonly provider?: ProviderKind;
321 readonly model?: string;323 readonly model?: string;
322 readonly effort?: string;324 readonly effort?: string;
325 readonly interactionMode?: "default" | "plan";
323 readonly createdAt: string;326 readonly createdAt: string;
324 }) {327 }) {
325 const thread = yield* resolveThread(input.threadId);328 const thread = yield* resolveThread(input.threadId);
...@@ -348,6 +351,7 @@ const make = Effect.gen(function* () {...@@ -348,6 +351,7 @@ const make = Effect.gen(function* () {
348 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),351 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
349 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),352 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
350 ...(input.effort !== undefined ? { effort: input.effort } : {}),353 ...(input.effort !== undefined ? { effort: input.effort } : {}),
354 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
351 });355 });
352 });356 });
353357
...@@ -461,6 +465,7 @@ const make = Effect.gen(function* () {...@@ -461,6 +465,7 @@ const make = Effect.gen(function* () {
461 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),465 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
462 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),466 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
463 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),467 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
468 interactionMode: event.payload.interactionMode,
464 createdAt: event.payload.createdAt,469 createdAt: event.payload.createdAt,
465 });470 });
466 });471 });
...@@ -535,6 +540,50 @@ const make = Effect.gen(function* () {...@@ -535,6 +540,50 @@ const make = Effect.gen(function* () {
535 );540 );
536 });541 });
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
538 const processSessionStopRequested = Effect.fnUntraced(function* (587 const processSessionStopRequested = Effect.fnUntraced(function* (
539 event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>,588 event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>,
540 ) {589 ) {
...@@ -583,6 +632,9 @@ const make = Effect.gen(function* () {...@@ -583,6 +632,9 @@ const make = Effect.gen(function* () {
583 case "thread.approval-response-requested":632 case "thread.approval-response-requested":
584 yield* processApprovalResponseRequested(event);633 yield* processApprovalResponseRequested(event);
585 return;634 return;
635 case "thread.user-input-response-requested":
636 yield* processUserInputResponseRequested(event);
637 return;
586 case "thread.session-stop-requested":638 case "thread.session-stop-requested":
587 yield* processSessionStopRequested(event);639 yield* processSessionStopRequested(event);
588 return;640 return;
...@@ -617,6 +669,7 @@ const make = Effect.gen(function* () {...@@ -617,6 +669,7 @@ const make = Effect.gen(function* () {
617 event.type !== "thread.turn-start-requested" &&669 event.type !== "thread.turn-start-requested" &&
618 event.type !== "thread.turn-interrupt-requested" &&670 event.type !== "thread.turn-interrupt-requested" &&
619 event.type !== "thread.approval-response-requested" &&671 event.type !== "thread.approval-response-requested" &&
672 event.type !== "thread.user-input-response-requested" &&
620 event.type !== "thread.session-stop-requested"673 event.type !== "thread.session-stop-requested"
621 ) {674 ) {
622 return Effect.void;675 return Effect.void;
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+264
...@@ -2,6 +2,7 @@ import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/cont...@@ -2,6 +2,7 @@ import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/cont
2import {2import {
3 ApprovalRequestId,3 ApprovalRequestId,
4 CommandId,4 CommandId,
5 DEFAULT_PROVIDER_INTERACTION_MODE,
5 EventId,6 EventId,
6 MessageId,7 MessageId,
7 ProjectId,8 ProjectId,
...@@ -59,6 +60,7 @@ function createProviderServiceHarness() {...@@ -59,6 +60,7 @@ function createProviderServiceHarness() {
59 sendTurn: () => unsupported(),60 sendTurn: () => unsupported(),
60 interruptTurn: () => unsupported(),61 interruptTurn: () => unsupported(),
61 respondToRequest: () => unsupported(),62 respondToRequest: () => unsupported(),
63 respondToUserInput: () => unsupported(),
62 stopSession: () => unsupported(),64 stopSession: () => unsupported(),
63 listSessions: () => Effect.succeed([]),65 listSessions: () => Effect.succeed([]),
64 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),66 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
...@@ -163,6 +165,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -163,6 +165,7 @@ describe("ProviderRuntimeIngestion", () => {
163 projectId: asProjectId("project-1"),165 projectId: asProjectId("project-1"),
164 title: "Thread",166 title: "Thread",
165 model: "gpt-5-codex",167 model: "gpt-5-codex",
168 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
166 runtimeMode: "approval-required",169 runtimeMode: "approval-required",
167 branch: null,170 branch: null,
168 worktreePath: null,171 worktreePath: null,
...@@ -630,6 +633,112 @@ describe("ProviderRuntimeIngestion", () => {...@@ -630,6 +633,112 @@ describe("ProviderRuntimeIngestion", () => {
630 expect(message?.streaming).toBe(false);633 expect(message?.streaming).toBe(false);
631 });634 });
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
633 it("buffers assistant deltas by default until completion", async () => {742 it("buffers assistant deltas by default until completion", async () => {
634 const harness = await createHarness();743 const harness = await createHarness();
635 const now = new Date().toISOString();744 const now = new Date().toISOString();
...@@ -716,6 +825,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -716,6 +825,7 @@ describe("ProviderRuntimeIngestion", () => {
716 attachments: [],825 attachments: [],
717 },826 },
718 assistantDeliveryMode: "streaming",827 assistantDeliveryMode: "streaming",
828 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
719 runtimeMode: "approval-required",829 runtimeMode: "approval-required",
720 createdAt: now,830 createdAt: now,
721 }),831 }),
...@@ -1217,6 +1327,160 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1217,6 +1327,160 @@ describe("ProviderRuntimeIngestion", () => {
1217 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");1327 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");
1218 });1328 });
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
1220 it("continues processing runtime events after a single event handler failure", async () => {1484 it("continues processing runtime events after a single event handler failure", async () => {
1221 const harness = await createHarness();1485 const harness = await createHarness();
1222 const now = new Date().toISOString();1486 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+167-6
...@@ -26,6 +26,8 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId...@@ -26,6 +26,8 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId
26const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered";26const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered";
27const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;27const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
28const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);28const 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);
29const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;31const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
30const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120);32const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120);
31const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;33const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;
...@@ -65,6 +67,14 @@ function truncateDetail(value: string, limit = 180): string {...@@ -65,6 +67,14 @@ function truncateDetail(value: string, limit = 180): string {
65 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;67 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
66}68}
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
68function asString(value: unknown): string | undefined {78function asString(value: unknown): string | undefined {
69 return typeof value === "string" ? value : undefined;79 return typeof value === "string" ? value : undefined;
70}80}
...@@ -168,6 +178,9 @@ function runtimeEventToActivities(...@@ -168,6 +178,9 @@ function runtimeEventToActivities(
168 })();178 })();
169 switch (event.type) {179 switch (event.type) {
170 case "request.opened": {180 case "request.opened": {
181 if (event.payload.requestType === "tool_user_input") {
182 return [];
183 }
171 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);184 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
172 return [185 return [
173 {186 {
...@@ -196,6 +209,9 @@ function runtimeEventToActivities(...@@ -196,6 +209,9 @@ function runtimeEventToActivities(
196 }209 }
197210
198 case "request.resolved": {211 case "request.resolved": {
212 if (event.payload.requestType === "tool_user_input") {
213 return [];
214 }
199 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);215 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
200 return [216 return [
201 {217 {
...@@ -273,6 +289,111 @@ function runtimeEventToActivities(...@@ -273,6 +289,111 @@ function runtimeEventToActivities(
273 ];289 ];
274 }290 }
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
276 case "item.updated": {397 case "item.updated": {
277 if (!isToolLifecycleItemType(event.payload.itemType)) {398 if (!isToolLifecycleItemType(event.payload.itemType)) {
278 return [];399 return [];
...@@ -359,6 +480,12 @@ const make = Effect.gen(function* () {...@@ -359,6 +480,12 @@ const make = Effect.gen(function* () {
359 lookup: () => Effect.succeed(new Set<MessageId>()),480 lookup: () => Effect.succeed(new Set<MessageId>()),
360 });481 });
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
362 const bufferedAssistantTextByMessageId = yield* Cache.make<MessageId, string>({489 const bufferedAssistantTextByMessageId = yield* Cache.make<MessageId, string>({
363 capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY,490 capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY,
364 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,491 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,
...@@ -418,6 +545,16 @@ const make = Effect.gen(function* () {...@@ -418,6 +545,16 @@ const make = Effect.gen(function* () {
418 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>545 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
419 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));546 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
421 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>558 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>
422 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(559 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
423 Effect.flatMap((existingText) =>560 Effect.flatMap((existingText) =>
...@@ -451,7 +588,10 @@ const make = Effect.gen(function* () {...@@ -451,7 +588,10 @@ const make = Effect.gen(function* () {
451 Cache.invalidate(bufferedAssistantTextByMessageId, messageId);588 Cache.invalidate(bufferedAssistantTextByMessageId, messageId);
452589
453 const clearAssistantMessageState = (messageId: MessageId) =>590 const clearAssistantMessageState = (messageId: MessageId) =>
454 clearBufferedAssistantText(messageId);591 Effect.all([
592 clearBufferedAssistantText(messageId),
593 Cache.invalidate(messageStreamKindByMessageId, messageId),
594 ]).pipe(Effect.asVoid);
455595
456 const finalizeAssistantMessage = (input: {596 const finalizeAssistantMessage = (input: {
457 event: ProviderRuntimeEvent;597 event: ProviderRuntimeEvent;
...@@ -464,9 +604,20 @@ const make = Effect.gen(function* () {...@@ -464,9 +604,20 @@ const make = Effect.gen(function* () {
464 fallbackText?: string;604 fallbackText?: string;
465 }) =>605 }) =>
466 Effect.gen(function* () {606 Effect.gen(function* () {
607 const messageStreamKind = yield* getMessageStreamKind(input.messageId);
467 const bufferedText = yield* takeBufferedAssistantText(input.messageId);608 const bufferedText = yield* takeBufferedAssistantText(input.messageId);
609 const bufferedFinalText =
610 bufferedText.length > 0
611 ? messageStreamKind === "plan_text"
612 ? (wrapProposedPlanMessage(bufferedText) ?? "")
613 : bufferedText
614 : "";
468 const text =615 const text =
469 bufferedText.length > 0 ? bufferedText : (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : "";616 bufferedFinalText.length > 0
617 ? bufferedFinalText
618 : (input.fallbackText?.trim().length ?? 0) > 0
619 ? input.fallbackText!
620 : "";
470621
471 if (text.length > 0) {622 if (text.length > 0) {
472 yield* orchestrationEngine.dispatch({623 yield* orchestrationEngine.dispatch({
...@@ -617,7 +768,8 @@ const make = Effect.gen(function* () {...@@ -617,7 +768,8 @@ const make = Effect.gen(function* () {
617 }768 }
618769
619 const assistantDelta =770 const assistantDelta =
620 event.type === "content.delta" && event.payload.streamKind === "assistant_text"771 event.type === "content.delta" &&
772 (event.payload.streamKind === "assistant_text" || event.payload.streamKind === "plan_text")
621 ? event.payload.delta773 ? event.payload.delta
622 : undefined;774 : undefined;
623775
...@@ -626,9 +778,14 @@ const make = Effect.gen(function* () {...@@ -626,9 +778,14 @@ const make = Effect.gen(function* () {
626 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,778 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
627 );779 );
628 const turnId = toTurnId(event.turnId);780 const turnId = toTurnId(event.turnId);
781 const messageStreamKind =
782 event.type === "content.delta" && event.payload.streamKind === "plan_text"
783 ? "plan_text"
784 : "assistant_text";
629 if (turnId) {785 if (turnId) {
630 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);786 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
631 }787 }
788 yield* rememberMessageStreamKind(assistantMessageId, messageStreamKind);
632789
633 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);790 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);
634 if (assistantDeliveryMode === "buffered") {791 if (assistantDeliveryMode === "buffered") {
...@@ -658,12 +815,16 @@ const make = Effect.gen(function* () {...@@ -658,12 +815,16 @@ const make = Effect.gen(function* () {
658 }815 }
659816
660 const assistantCompletion =817 const assistantCompletion =
661 event.type === "item.completed" && event.payload.itemType === "assistant_message"818 event.type === "item.completed" &&
819 (event.payload.itemType === "assistant_message" || event.payload.itemType === "plan")
662 ? {820 ? {
663 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),821 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),
664 fallbackText: event.payload.detail,822 fallbackText:
823 event.payload.itemType === "plan"
824 ? wrapProposedPlanMessage(event.payload.detail)
825 : event.payload.detail,
665 }826 }
666 : undefined;827 : undefined;
667828
668 if (assistantCompletion) {829 if (assistantCompletion) {
669 const assistantMessageId = assistantCompletion.messageId;830 const assistantMessageId = assistantCompletion.messageId;
apps/server/src/orchestration/Schemas.ts+2
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
6 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,6 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
7 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,7 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
8 ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
8 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,9 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
9 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,10 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
10 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,11 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
...@@ -26,6 +27,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;...@@ -26,6 +27,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
26export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;27export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
27export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;28export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
28export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;29export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
30export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
29export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;31export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
3032
31export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;33export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+6
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2import {2import {
3 MessageId,3 MessageId,
4 CommandId,4 CommandId,
5 DEFAULT_PROVIDER_INTERACTION_MODE,
5 ProjectId,6 ProjectId,
6 ThreadId,7 ThreadId,
7 type OrchestrationCommand,8 type OrchestrationCommand,
...@@ -50,6 +51,7 @@ const readModel: OrchestrationReadModel = {...@@ -50,6 +51,7 @@ const readModel: OrchestrationReadModel = {
50 projectId: ProjectId.makeUnsafe("project-a"),51 projectId: ProjectId.makeUnsafe("project-a"),
51 title: "Thread A",52 title: "Thread A",
52 model: "gpt-5-codex",53 model: "gpt-5-codex",
54 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
53 runtimeMode: "full-access",55 runtimeMode: "full-access",
54 branch: null,56 branch: null,
55 worktreePath: null,57 worktreePath: null,
...@@ -67,6 +69,7 @@ const readModel: OrchestrationReadModel = {...@@ -67,6 +69,7 @@ const readModel: OrchestrationReadModel = {
67 projectId: ProjectId.makeUnsafe("project-b"),69 projectId: ProjectId.makeUnsafe("project-b"),
68 title: "Thread B",70 title: "Thread B",
69 model: "gpt-5-codex",71 model: "gpt-5-codex",
72 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
70 runtimeMode: "full-access",73 runtimeMode: "full-access",
71 branch: null,74 branch: null,
72 worktreePath: null,75 worktreePath: null,
...@@ -92,6 +95,7 @@ const messageSendCommand: OrchestrationCommand = {...@@ -92,6 +95,7 @@ const messageSendCommand: OrchestrationCommand = {
92 text: "hello",95 text: "hello",
93 attachments: [],96 attachments: [],
94 },97 },
98 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
95 runtimeMode: "approval-required",99 runtimeMode: "approval-required",
96 createdAt: now,100 createdAt: now,
97};101};
...@@ -139,6 +143,7 @@ describe("commandInvariants", () => {...@@ -139,6 +143,7 @@ describe("commandInvariants", () => {
139 projectId: ProjectId.makeUnsafe("project-a"),143 projectId: ProjectId.makeUnsafe("project-a"),
140 title: "new",144 title: "new",
141 model: "gpt-5-codex",145 model: "gpt-5-codex",
146 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
142 runtimeMode: "full-access",147 runtimeMode: "full-access",
143 branch: null,148 branch: null,
144 worktreePath: null,149 worktreePath: null,
...@@ -159,6 +164,7 @@ describe("commandInvariants", () => {...@@ -159,6 +164,7 @@ describe("commandInvariants", () => {
159 projectId: ProjectId.makeUnsafe("project-a"),164 projectId: ProjectId.makeUnsafe("project-a"),
160 title: "dup",165 title: "dup",
161 model: "gpt-5-codex",166 model: "gpt-5-codex",
167 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
162 runtimeMode: "full-access",168 runtimeMode: "full-access",
163 branch: null,169 branch: null,
164 worktreePath: null,170 worktreePath: null,
apps/server/src/orchestration/decider.projectScripts.test.ts+90-1
...@@ -1,4 +1,11 @@...@@ -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";
2import { describe, expect, it } from "vitest";9import { describe, expect, it } from "vitest";
3import { Effect } from "effect";10import { Effect } from "effect";
411
...@@ -130,6 +137,7 @@ describe("decider project scripts", () => {...@@ -130,6 +137,7 @@ describe("decider project scripts", () => {
130 projectId: asProjectId("project-1"),137 projectId: asProjectId("project-1"),
131 title: "Thread",138 title: "Thread",
132 model: "gpt-5-codex",139 model: "gpt-5-codex",
140 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
133 runtimeMode: "approval-required",141 runtimeMode: "approval-required",
134 branch: null,142 branch: null,
135 worktreePath: null,143 worktreePath: null,
...@@ -154,6 +162,7 @@ describe("decider project scripts", () => {...@@ -154,6 +162,7 @@ describe("decider project scripts", () => {
154 provider: "claudeCode",162 provider: "claudeCode",
155 model: "gpt-5",163 model: "gpt-5",
156 effort: "high",164 effort: "high",
165 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
157 runtimeMode: "approval-required",166 runtimeMode: "approval-required",
158 createdAt: now,167 createdAt: now,
159 },168 },
...@@ -225,6 +234,7 @@ describe("decider project scripts", () => {...@@ -225,6 +234,7 @@ describe("decider project scripts", () => {
225 projectId: asProjectId("project-1"),234 projectId: asProjectId("project-1"),
226 title: "Thread",235 title: "Thread",
227 model: "gpt-5-codex",236 model: "gpt-5-codex",
237 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
228 runtimeMode: "full-access",238 runtimeMode: "full-access",
229 branch: null,239 branch: null,
230 worktreePath: null,240 worktreePath: null,
...@@ -259,4 +269,83 @@ describe("decider project scripts", () => {...@@ -259,4 +269,83 @@ describe("decider project scripts", () => {
259 },269 },
260 });270 });
261 });271 });
272
273 it("emits thread.interaction-mode-set from thread.interaction-mode.set", async () => {
274 const now = new Date().toISOString();
275 const initial = createEmptyReadModel(now);
276 const withProject = await Effect.runPromise(
277 projectEvent(initial, {
278 sequence: 1,
279 eventId: asEventId("evt-project-create"),
280 aggregateKind: "project",
281 aggregateId: asProjectId("project-1"),
282 type: "project.created",
283 occurredAt: now,
284 commandId: CommandId.makeUnsafe("cmd-project-create"),
285 causationEventId: null,
286 correlationId: CommandId.makeUnsafe("cmd-project-create"),
287 metadata: {},
288 payload: {
289 projectId: asProjectId("project-1"),
290 title: "Project",
291 workspaceRoot: "/tmp/project",
292 defaultModel: null,
293 scripts: [],
294 createdAt: now,
295 updatedAt: now,
296 },
297 }),
298 );
299 const readModel = await Effect.runPromise(
300 projectEvent(withProject, {
301 sequence: 2,
302 eventId: asEventId("evt-thread-create"),
303 aggregateKind: "thread",
304 aggregateId: ThreadId.makeUnsafe("thread-1"),
305 type: "thread.created",
306 occurredAt: now,
307 commandId: CommandId.makeUnsafe("cmd-thread-create"),
308 causationEventId: null,
309 correlationId: CommandId.makeUnsafe("cmd-thread-create"),
310 metadata: {},
311 payload: {
312 threadId: ThreadId.makeUnsafe("thread-1"),
313 projectId: asProjectId("project-1"),
314 title: "Thread",
315 model: "gpt-5-codex",
316 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
317 runtimeMode: "approval-required",
318 branch: null,
319 worktreePath: null,
320 createdAt: now,
321 updatedAt: now,
322 },
323 }),
324 );
325
326 const result = await Effect.runPromise(
327 decideOrchestrationCommand({
328 command: {
329 type: "thread.interaction-mode.set",
330 commandId: CommandId.makeUnsafe("cmd-interaction-mode-set"),
331 threadId: ThreadId.makeUnsafe("thread-1"),
332 interactionMode: "plan",
333 createdAt: now,
334 },
335 readModel,
336 }),
337 );
338
339 const singleResult = Array.isArray(result) ? null : result;
340 if (singleResult === null) {
341 throw new Error("Expected a single interaction-mode-set event.");
342 }
343 expect(singleResult).toMatchObject({
344 type: "thread.interaction-mode-set",
345 payload: {
346 threadId: ThreadId.makeUnsafe("thread-1"),
347 interactionMode: "plan",
348 },
349 });
350 });
262});351});
apps/server/src/orchestration/decider.ts+53
...@@ -158,6 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -158,6 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
158 title: command.title,158 title: command.title,
159 model: command.model,159 model: command.model,
160 runtimeMode: command.runtimeMode,160 runtimeMode: command.runtimeMode,
161 interactionMode: command.interactionMode,
161 branch: command.branch,162 branch: command.branch,
162 worktreePath: command.worktreePath,163 worktreePath: command.worktreePath,
163 createdAt: command.createdAt,164 createdAt: command.createdAt,
...@@ -237,6 +238,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -237,6 +238,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
237 };238 };
238 }239 }
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
240 case "thread.turn.start": {264 case "thread.turn.start": {
241 yield* requireThread({265 yield* requireThread({
242 readModel,266 readModel,
...@@ -282,6 +306,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -282,6 +306,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
282 runtimeMode:306 runtimeMode:
283 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??307 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??
284 command.runtimeMode,308 command.runtimeMode,
309 interactionMode:
310 readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ??
311 command.interactionMode,
285 createdAt: command.createdAt,312 createdAt: command.createdAt,
286 },313 },
287 };314 };
...@@ -336,6 +363,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -336,6 +363,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
336 };363 };
337 }364 }
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
339 case "thread.checkpoint.revert": {392 case "thread.checkpoint.revert": {
340 yield* requireThread({393 yield* requireThread({
341 readModel,394 readModel,
apps/server/src/orchestration/projector.ts+18
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 ThreadActivityAppendedPayload,16 ThreadActivityAppendedPayload,
17 ThreadCreatedPayload,17 ThreadCreatedPayload,
18 ThreadDeletedPayload,18 ThreadDeletedPayload,
19 ThreadInteractionModeSetPayload,
19 ThreadMetaUpdatedPayload,20 ThreadMetaUpdatedPayload,
20 ThreadRuntimeModeSetPayload,21 ThreadRuntimeModeSetPayload,
21 ThreadRevertedPayload,22 ThreadRevertedPayload,
...@@ -243,6 +244,7 @@ export function projectEvent(...@@ -243,6 +244,7 @@ export function projectEvent(
243 title: payload.title,244 title: payload.title,
244 model: payload.model,245 model: payload.model,
245 runtimeMode: payload.runtimeMode,246 runtimeMode: payload.runtimeMode,
247 interactionMode: payload.interactionMode,
246 branch: payload.branch,248 branch: payload.branch,
247 worktreePath: payload.worktreePath,249 worktreePath: payload.worktreePath,
248 latestTurn: null,250 latestTurn: null,
...@@ -307,6 +309,22 @@ export function projectEvent(...@@ -307,6 +309,22 @@ export function projectEvent(
307 })),309 })),
308 );310 );
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
310 case "thread.message-sent":328 case "thread.message-sent":
311 return Effect.gen(function* () {329 return Effect.gen(function* () {
312 const payload = yield* decodeForEvent(330 const payload = yield* decodeForEvent(
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -25,6 +25,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -25,6 +25,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
25 title,25 title,
26 model,26 model,
27 runtime_mode,27 runtime_mode,
28 interaction_mode,
28 branch,29 branch,
29 worktree_path,30 worktree_path,
30 latest_turn_id,31 latest_turn_id,
...@@ -38,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -38,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
38 ${row.title},39 ${row.title},
39 ${row.model},40 ${row.model},
40 ${row.runtimeMode},41 ${row.runtimeMode},
42 ${row.interactionMode},
41 ${row.branch},43 ${row.branch},
42 ${row.worktreePath},44 ${row.worktreePath},
43 ${row.latestTurnId},45 ${row.latestTurnId},
...@@ -51,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -51,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
51 title = excluded.title,53 title = excluded.title,
52 model = excluded.model,54 model = excluded.model,
53 runtime_mode = excluded.runtime_mode,55 runtime_mode = excluded.runtime_mode,
56 interaction_mode = excluded.interaction_mode,
54 branch = excluded.branch,57 branch = excluded.branch,
55 worktree_path = excluded.worktree_path,58 worktree_path = excluded.worktree_path,
56 latest_turn_id = excluded.latest_turn_id,59 latest_turn_id = excluded.latest_turn_id,
...@@ -71,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -71,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
71 title,74 title,
72 model,75 model,
73 runtime_mode AS "runtimeMode",76 runtime_mode AS "runtimeMode",
77 interaction_mode AS "interactionMode",
74 branch,78 branch,
75 worktree_path AS "worktreePath",79 worktree_path AS "worktreePath",
76 latest_turn_id AS "latestTurnId",80 latest_turn_id AS "latestTurnId",
...@@ -93,6 +97,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -93,6 +97,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
93 title,97 title,
94 model,98 model,
95 runtime_mode AS "runtimeMode",99 runtime_mode AS "runtimeMode",
100 interaction_mode AS "interactionMode",
96 branch,101 branch,
97 worktree_path AS "worktreePath",102 worktree_path AS "worktreePath",
98 latest_turn_id AS "latestTurnId",103 latest_turn_id AS "latestTurnId",
apps/server/src/persistence/Migrations.ts+2
...@@ -23,6 +23,7 @@ import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"...@@ -23,6 +23,7 @@ import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"
23import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts";23import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts";
24import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";24import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";
25import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";25import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";
26import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts";
26import { Effect } from "effect";27import { Effect } from "effect";
2728
28/**29/**
...@@ -47,6 +48,7 @@ const loader = Migrator.fromRecord({...@@ -47,6 +48,7 @@ const loader = Migrator.fromRecord({
47 "9_ProviderSessionRuntimeMode": Migration0009,48 "9_ProviderSessionRuntimeMode": Migration0009,
48 "10_ProjectionThreadsRuntimeMode": Migration0010,49 "10_ProjectionThreadsRuntimeMode": Migration0010,
49 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,50 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
51 "12_ProjectionThreadsInteractionMode": Migration0012,
50});52});
5153
52/**54/**
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 @@...@@ -6,7 +6,14 @@
6 *6 *
7 * @module ProjectionThreadRepository7 * @module ProjectionThreadRepository
8 */8 */
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";
10import { Option, Schema, ServiceMap } from "effect";17import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";18import type { Effect } from "effect";
1219
...@@ -18,6 +25,7 @@ export const ProjectionThread = Schema.Struct({...@@ -18,6 +25,7 @@ export const ProjectionThread = Schema.Struct({
18 title: Schema.String,25 title: Schema.String,
19 model: Schema.String,26 model: Schema.String,
20 runtimeMode: RuntimeMode,27 runtimeMode: RuntimeMode,
28 interactionMode: ProviderInteractionMode,
21 branch: Schema.NullOr(Schema.String),29 branch: Schema.NullOr(Schema.String),
22 worktreePath: Schema.NullOr(Schema.String),30 worktreePath: Schema.NullOr(Schema.String),
23 latestTurnId: Schema.NullOr(TurnId),31 latestTurnId: Schema.NullOr(TurnId),
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+14
...@@ -1781,6 +1781,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1781,6 +1781,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1781 yield* Deferred.succeed(pending.decision, decision);1781 yield* Deferred.succeed(pending.decision, decision);
1782 });1782 });
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
1784 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>1797 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>
1785 Effect.gen(function* () {1798 Effect.gen(function* () {
1786 const context = yield* requireSession(threadId);1799 const context = yield* requireSession(threadId);
...@@ -1830,6 +1843,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1830,6 +1843,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1830 readThread,1843 readThread,
1831 rollbackThread,1844 rollbackThread,
1832 respondToRequest,1845 respondToRequest,
1846 respondToUserInput,
1833 stopSession,1847 stopSession,
1834 listSessions,1848 listSessions,
1835 hasSession,1849 hasSession,
apps/server/src/provider/Layers/CodexAdapter.test.ts+233
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 type ProviderSession,7 type ProviderSession,
8 type ProviderSessionStartInput,8 type ProviderSessionStartInput,
9 type ProviderTurnStartResult,9 type ProviderTurnStartResult,
10 type ProviderUserInputAnswers,
10 ThreadId,11 ThreadId,
11 TurnId,12 TurnId,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
...@@ -76,6 +77,14 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -76,6 +77,14 @@ class FakeCodexManager extends CodexAppServerManager {
76 ): Promise<void> => undefined,77 ): Promise<void> => undefined,
77 );78 );
7879
80 public respondToUserInputImpl = vi.fn(
81 async (
82 _threadId: ThreadId,
83 _requestId: ApprovalRequestId,
84 _answers: ProviderUserInputAnswers,
85 ): Promise<void> => undefined,
86 );
87
79 public stopAllImpl = vi.fn(() => undefined);88 public stopAllImpl = vi.fn(() => undefined);
8089
81 override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {90 override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
...@@ -106,6 +115,14 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -106,6 +115,14 @@ class FakeCodexManager extends CodexAppServerManager {
106 return this.respondToRequestImpl(threadId, requestId, decision);115 return this.respondToRequestImpl(threadId, requestId, decision);
107 }116 }
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
109 override stopSession(_threadId: ThreadId): void {}126 override stopSession(_threadId: ThreadId): void {}
110127
111 override listSessions(): ProviderSession[] {128 override listSessions(): ProviderSession[] {
...@@ -253,6 +270,46 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -253,6 +270,46 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
253 }),270 }),
254 );271 );
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
256 it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () =>313 it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () =>
257 Effect.gen(function* () {314 Effect.gen(function* () {
258 const adapter = yield* CodexAdapter;315 const adapter = yield* CodexAdapter;
...@@ -361,6 +418,182 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -361,6 +418,182 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
361 }418 }
362 }),419 }),
363 );420 );
421
422 it.effect("maps requestUserInput requests and answered notifications to canonical user-input events", () =>
423 Effect.gen(function* () {
424 const adapter = yield* CodexAdapter;
425 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
426 Effect.forkChild,
427 );
428
429 lifecycleManager.emit("event", {
430 id: asEventId("evt-user-input-requested"),
431 kind: "request",
432 provider: "codex",
433 threadId: asThreadId("thread-1"),
434 createdAt: new Date().toISOString(),
435 method: "item/tool/requestUserInput",
436 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
437 payload: {
438 questions: [
439 {
440 id: "sandbox_mode",
441 header: "Sandbox",
442 question: "Which mode should be used?",
443 options: [
444 {
445 label: "workspace-write",
446 description: "Allow workspace writes only",
447 },
448 ],
449 },
450 ],
451 },
452 } satisfies ProviderEvent);
453 lifecycleManager.emit("event", {
454 id: asEventId("evt-user-input-resolved"),
455 kind: "notification",
456 provider: "codex",
457 threadId: asThreadId("thread-1"),
458 createdAt: new Date().toISOString(),
459 method: "item/tool/requestUserInput/answered",
460 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
461 payload: {
462 answers: {
463 sandbox_mode: {
464 answers: ["workspace-write"],
465 },
466 },
467 },
468 } satisfies ProviderEvent);
469
470 const events = Array.from(yield* Fiber.join(eventsFiber));
471 assert.equal(events[0]?.type, "user-input.requested");
472 if (events[0]?.type === "user-input.requested") {
473 assert.equal(events[0].requestId, "req-user-input-1");
474 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
475 }
476
477 assert.equal(events[1]?.type, "user-input.resolved");
478 if (events[1]?.type === "user-input.resolved") {
479 assert.equal(events[1].requestId, "req-user-input-1");
480 assert.deepEqual(events[1].payload.answers, {
481 sandbox_mode: "workspace-write",
482 });
483 }
484 }),
485 );
486
487 it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () =>
488 Effect.gen(function* () {
489 const adapter = yield* CodexAdapter;
490 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe(
491 Effect.forkChild,
492 );
493
494 lifecycleManager.emit("event", {
495 id: asEventId("evt-codex-task-started"),
496 kind: "notification",
497 provider: "codex",
498 threadId: asThreadId("thread-1"),
499 createdAt: new Date().toISOString(),
500 method: "codex/event/task_started",
501 payload: {
502 id: "turn-structured-1",
503 msg: {
504 type: "task_started",
505 turn_id: "turn-structured-1",
506 collaboration_mode_kind: "plan",
507 },
508 },
509 } satisfies ProviderEvent);
510
511 lifecycleManager.emit("event", {
512 id: asEventId("evt-codex-agent-reasoning"),
513 kind: "notification",
514 provider: "codex",
515 threadId: asThreadId("thread-1"),
516 createdAt: new Date().toISOString(),
517 method: "codex/event/agent_reasoning",
518 payload: {
519 id: "turn-structured-1",
520 msg: {
521 type: "agent_reasoning",
522 text: "Need to compare both transport layers before finalizing the plan.",
523 },
524 },
525 } satisfies ProviderEvent);
526
527 lifecycleManager.emit("event", {
528 id: asEventId("evt-codex-reasoning-delta"),
529 kind: "notification",
530 provider: "codex",
531 threadId: asThreadId("thread-1"),
532 createdAt: new Date().toISOString(),
533 method: "codex/event/reasoning_content_delta",
534 payload: {
535 id: "turn-structured-1",
536 msg: {
537 type: "reasoning_content_delta",
538 turn_id: "turn-structured-1",
539 item_id: "rs_reasoning_1",
540 delta: "**Compare** transport boundaries",
541 summary_index: 0,
542 },
543 },
544 } satisfies ProviderEvent);
545
546 lifecycleManager.emit("event", {
547 id: asEventId("evt-codex-task-complete"),
548 kind: "notification",
549 provider: "codex",
550 threadId: asThreadId("thread-1"),
551 createdAt: new Date().toISOString(),
552 method: "codex/event/task_complete",
553 payload: {
554 id: "turn-structured-1",
555 msg: {
556 type: "task_complete",
557 turn_id: "turn-structured-1",
558 last_agent_message: "<proposed_plan>\n# Ship it\n</proposed_plan>",
559 },
560 },
561 } satisfies ProviderEvent);
562
563 const events = Array.from(yield* Fiber.join(eventsFiber));
564
565 assert.equal(events[0]?.type, "task.started");
566 if (events[0]?.type === "task.started") {
567 assert.equal(events[0].turnId, "turn-structured-1");
568 assert.equal(events[0].payload.taskId, "turn-structured-1");
569 assert.equal(events[0].payload.taskType, "plan");
570 }
571
572 assert.equal(events[1]?.type, "task.progress");
573 if (events[1]?.type === "task.progress") {
574 assert.equal(events[1].payload.taskId, "turn-structured-1");
575 assert.equal(
576 events[1].payload.description,
577 "Need to compare both transport layers before finalizing the plan.",
578 );
579 }
580
581 assert.equal(events[2]?.type, "content.delta");
582 if (events[2]?.type === "content.delta") {
583 assert.equal(events[2].turnId, "turn-structured-1");
584 assert.equal(events[2].itemId, "rs_reasoning_1");
585 assert.equal(events[2].payload.streamKind, "reasoning_summary_text");
586 assert.equal(events[2].payload.summaryIndex, 0);
587 }
588
589 assert.equal(events[3]?.type, "task.completed");
590 if (events[3]?.type === "task.completed") {
591 assert.equal(events[3].turnId, "turn-structured-1");
592 assert.equal(events[3].payload.taskId, "turn-structured-1");
593 assert.equal(events[3].payload.summary, "<proposed_plan>\n# Ship it\n</proposed_plan>");
594 }
595 }),
596 );
364});597});
365598
366afterAll(() => {599afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+255
...@@ -11,11 +11,14 @@ import {...@@ -11,11 +11,14 @@ import {
11 type CanonicalRequestType,11 type CanonicalRequestType,
12 type ProviderEvent,12 type ProviderEvent,
13 type ProviderRuntimeEvent,13 type ProviderRuntimeEvent,
14 type ProviderUserInputAnswers,
14 RuntimeItemId,15 RuntimeItemId,
15 RuntimeRequestId,16 RuntimeRequestId,
17 RuntimeTaskId,
16 ProviderApprovalDecision,18 ProviderApprovalDecision,
17 ProviderItemId,19 ProviderItemId,
18 ThreadId,20 ThreadId,
21 TurnId,
19} from "@t3tools/contracts";22} from "@t3tools/contracts";
20import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect";23import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect";
2124
...@@ -95,6 +98,10 @@ function asString(value: unknown): string | undefined {...@@ -95,6 +98,10 @@ function asString(value: unknown): string | undefined {
95 return typeof value === "string" ? value : undefined;98 return typeof value === "string" ? value : undefined;
96}99}
97100
101function asArray(value: unknown): unknown[] | undefined {
102 return Array.isArray(value) ? value : undefined;
103}
104
98function asNumber(value: unknown): number | undefined {105function asNumber(value: unknown): number | undefined {
99 return typeof value === "number" && Number.isFinite(value) ? value : undefined;106 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
100}107}
...@@ -249,6 +256,88 @@ function toRequestTypeFromResolvedPayload(...@@ -249,6 +256,88 @@ function toRequestTypeFromResolvedPayload(
249 return "unknown";256 return "unknown";
250}257}
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
252function toThreadState(341function toThreadState(
253 value: unknown,342 value: unknown,
254): "active" | "idle" | "archived" | "closed" | "compacted" | "error" {343): "active" | "idle" | "archived" | "closed" | "compacted" | "error" {
...@@ -304,6 +393,46 @@ function asRuntimeRequestId(requestId: string): RuntimeRequestId {...@@ -304,6 +393,46 @@ function asRuntimeRequestId(requestId: string): RuntimeRequestId {
304 return RuntimeRequestId.makeUnsafe(requestId);393 return RuntimeRequestId.makeUnsafe(requestId);
305}394}
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
307function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent["raw"]>["source"] {436function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent["raw"]>["source"] {
308 return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification";437 return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification";
309}438}
...@@ -404,6 +533,22 @@ function mapToRuntimeEvents(...@@ -404,6 +533,22 @@ function mapToRuntimeEvents(
404 }533 }
405534
406 if (event.kind === "request") {535 if (event.kind === "request") {
536 if (event.method === "item/tool/requestUserInput") {
537 const questions = toUserInputQuestions(payload);
538 if (!questions) {
539 return [];
540 }
541 return [
542 {
543 ...runtimeEventBase(event, canonicalThreadId),
544 type: "user-input.requested",
545 payload: {
546 questions,
547 },
548 },
549 ];
550 }
551
407 const detail =552 const detail =
408 asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt);553 asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt);
409 return [554 return [
...@@ -737,6 +882,102 @@ function mapToRuntimeEvents(...@@ -737,6 +882,102 @@ function mapToRuntimeEvents(
737 ];882 ];
738 }883 }
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
740 if (event.method === "model/rerouted") {981 if (event.method === "model/rerouted") {
741 return [982 return [
742 {983 {
...@@ -1034,6 +1275,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1034,6 +1275,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1034 ...(input.input !== undefined ? { input: input.input } : {}),1275 ...(input.input !== undefined ? { input: input.input } : {}),
1035 ...(input.model !== undefined ? { model: input.model } : {}),1276 ...(input.model !== undefined ? { model: input.model } : {}),
1036 ...(input.effort !== undefined ? { effort: input.effort } : {}),1277 ...(input.effort !== undefined ? { effort: input.effort } : {}),
1278 ...(input.interactionMode !== undefined
1279 ? { interactionMode: input.interactionMode }
1280 : {}),
1037 ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}),1281 ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}),
1038 };1282 };
1039 return manager.sendTurn(managerInput);1283 return manager.sendTurn(managerInput);
...@@ -1096,6 +1340,16 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1096,6 +1340,16 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1096 catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause),1340 catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause),
1097 });1341 });
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
1099 const stopSession: CodexAdapterShape["stopSession"] = (threadId) =>1353 const stopSession: CodexAdapterShape["stopSession"] = (threadId) =>
1100 Effect.sync(() => {1354 Effect.sync(() => {
1101 manager.stopSession(threadId);1355 manager.stopSession(threadId);
...@@ -1163,6 +1417,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1163,6 +1417,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1163 readThread,1417 readThread,
1164 rollbackThread,1418 rollbackThread,
1165 respondToRequest,1419 respondToRequest,
1420 respondToUserInput,
1166 stopSession,1421 stopSession,
1167 listSessions,1422 listSessions,
1168 hasSession,1423 hasSession,
apps/server/src/provider/Layers/CursorAdapter.ts+14
...@@ -1490,6 +1490,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1490,6 +1490,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1490 });1490 });
1491 });1491 });
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
1493 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>1506 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
1494 Effect.gen(function* () {1507 Effect.gen(function* () {
1495 const context = yield* requireSession(threadId);1508 const context = yield* requireSession(threadId);
...@@ -1539,6 +1552,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1539,6 +1552,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1539 readThread,1552 readThread,
1540 rollbackThread,1553 rollbackThread,
1541 respondToRequest,1554 respondToRequest,
1555 respondToUserInput,
1542 stopSession,1556 stopSession,
1543 listSessions,1557 listSessions,
1544 hasSession,1558 hasSession,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+3
...@@ -19,6 +19,7 @@ const fakeCodexAdapter: CodexAdapterShape = {...@@ -19,6 +19,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
19 sendTurn: vi.fn(),19 sendTurn: vi.fn(),
20 interruptTurn: vi.fn(),20 interruptTurn: vi.fn(),
21 respondToRequest: vi.fn(),21 respondToRequest: vi.fn(),
22 respondToUserInput: vi.fn(),
22 stopSession: vi.fn(),23 stopSession: vi.fn(),
23 listSessions: vi.fn(),24 listSessions: vi.fn(),
24 hasSession: vi.fn(),25 hasSession: vi.fn(),
...@@ -35,6 +36,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {...@@ -35,6 +36,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
35 sendTurn: vi.fn(),36 sendTurn: vi.fn(),
36 interruptTurn: vi.fn(),37 interruptTurn: vi.fn(),
37 respondToRequest: vi.fn(),38 respondToRequest: vi.fn(),
39 respondToUserInput: vi.fn(),
38 stopSession: vi.fn(),40 stopSession: vi.fn(),
39 listSessions: vi.fn(),41 listSessions: vi.fn(),
40 hasSession: vi.fn(),42 hasSession: vi.fn(),
...@@ -51,6 +53,7 @@ const fakeCursorAdapter: CursorAdapterShape = {...@@ -51,6 +53,7 @@ const fakeCursorAdapter: CursorAdapterShape = {
51 sendTurn: vi.fn(),53 sendTurn: vi.fn(),
52 interruptTurn: vi.fn(),54 interruptTurn: vi.fn(),
53 respondToRequest: vi.fn(),55 respondToRequest: vi.fn(),
56 respondToUserInput: vi.fn(),
54 stopSession: vi.fn(),57 stopSession: vi.fn(),
55 listSessions: vi.fn(),58 listSessions: vi.fn(),
56 hasSession: vi.fn(),59 hasSession: vi.fn(),
apps/server/src/provider/Layers/ProviderService.test.ts+36-7
...@@ -116,6 +116,14 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -116,6 +116,14 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
116 ): Effect.Effect<void, ProviderAdapterError> => Effect.void,116 ): Effect.Effect<void, ProviderAdapterError> => Effect.void,
117 );117 );
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
119 const stopSession = vi.fn(127 const stopSession = vi.fn(
120 (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> =>128 (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> =>
121 Effect.sync(() => {129 Effect.sync(() => {
...@@ -172,6 +180,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -172,6 +180,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
172 sendTurn,180 sendTurn,
173 interruptTurn,181 interruptTurn,
174 respondToRequest,182 respondToRequest,
183 respondToUserInput,
175 stopSession,184 stopSession,
176 listSessions,185 listSessions,
177 hasSession,186 hasSession,
...@@ -192,6 +201,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -192,6 +201,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
192 sendTurn,201 sendTurn,
193 interruptTurn,202 interruptTurn,
194 respondToRequest,203 respondToRequest,
204 respondToUserInput,
195 stopSession,205 stopSession,
196 listSessions,206 listSessions,
197 hasSession,207 hasSession,
...@@ -395,12 +405,12 @@ it.effect(...@@ -395,12 +405,12 @@ it.effect(
395 provider?: string;405 provider?: string;
396 cwd?: string;406 cwd?: string;
397 resumeCursor?: unknown;407 resumeCursor?: unknown;
398 resumeThreadId?: string;408 threadId?: string;
399 };409 };
400 assert.equal(startPayload.provider, "codex");410 assert.equal(startPayload.provider, "codex");
401 assert.equal(startPayload.cwd, "/tmp/project");411 assert.equal(startPayload.cwd, "/tmp/project");
402 assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor);412 assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor);
403 assert.equal(startPayload.resumeThreadId, "thread-1");413 assert.equal(startPayload.threadId, startedSession.threadId);
404 }414 }
405 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);415 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);
406 const rollbackCall = secondCodex.rollbackThread.mock.calls[0];416 const rollbackCall = secondCodex.rollbackThread.mock.calls[0];
...@@ -446,6 +456,23 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -446,6 +456,23 @@ routing.layer("ProviderServiceLive routing", (it) => {
446 [session.threadId, asRequestId("req-1"), "accept"],456 [session.threadId, asRequestId("req-1"), "accept"],
447 ]);457 ]);
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
449 yield* provider.rollbackConversation({476 yield* provider.rollbackConversation({
450 threadId: session.threadId,477 threadId: session.threadId,
451 numTurns: 0,478 numTurns: 0,
...@@ -519,12 +546,12 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -519,12 +546,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
519 provider?: string;546 provider?: string;
520 cwd?: string;547 cwd?: string;
521 resumeCursor?: unknown;548 resumeCursor?: unknown;
522 resumeThreadId?: string;549 threadId?: string;
523 };550 };
524 assert.equal(startPayload.provider, "codex");551 assert.equal(startPayload.provider, "codex");
525 assert.equal(startPayload.cwd, "/tmp/project");552 assert.equal(startPayload.cwd, "/tmp/project");
526 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);553 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
527 assert.equal(startPayload.resumeThreadId, "thread-2");554 assert.equal(startPayload.threadId, initial.threadId);
528 }555 }
529 assert.equal(routing.codex.rollbackThread.mock.calls.length, 1);556 assert.equal(routing.codex.rollbackThread.mock.calls.length, 1);
530 const rollbackCall = routing.codex.rollbackThread.mock.calls[0];557 const rollbackCall = routing.codex.rollbackThread.mock.calls[0];
...@@ -561,12 +588,12 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -561,12 +588,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
561 provider?: string;588 provider?: string;
562 cwd?: string;589 cwd?: string;
563 resumeCursor?: unknown;590 resumeCursor?: unknown;
564 resumeThreadId?: string;591 threadId?: string;
565 };592 };
566 assert.equal(startPayload.provider, "codex");593 assert.equal(startPayload.provider, "codex");
567 assert.equal(startPayload.cwd, "/tmp/project-send-turn");594 assert.equal(startPayload.cwd, "/tmp/project-send-turn");
568 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);595 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
569 assert.equal(startPayload.resumeThreadId, "thread-4");596 assert.equal(startPayload.threadId, initial.threadId);
570 }597 }
571 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);598 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
572 }),599 }),
...@@ -629,7 +656,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -629,7 +656,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
629 };656 };
630 assert.equal(runtimePayload.cwd, process.cwd());657 assert.equal(runtimePayload.cwd, process.cwd());
631 assert.equal(runtimePayload.model, null);658 assert.equal(runtimePayload.model, null);
632 assert.equal(runtimePayload.activeTurnId, "turn-1");659 assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`);
633 assert.equal(runtimePayload.lastError, null);660 assert.equal(runtimePayload.lastError, null);
634 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");661 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
635 }662 }
...@@ -822,7 +849,9 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -822,7 +849,9 @@ validation.layer("ProviderServiceLive validation", (it) => {
822849
823 const failure = yield* Effect.result(850 const failure = yield* Effect.result(
824 provider.startSession(asThreadId("thread-validation"), {851 provider.startSession(asThreadId("thread-validation"), {
852 threadId: asThreadId("thread-validation"),
825 provider: "invalid-provider",853 provider: "invalid-provider",
854 runtimeMode: "full-access",
826 } as never),855 } as never),
827 );856 );
828857
apps/server/src/provider/Layers/ProviderService.ts+17
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 ThreadId,14 ThreadId,
15 ProviderInterruptTurnInput,15 ProviderInterruptTurnInput,
16 ProviderRespondToRequestInput,16 ProviderRespondToRequestInput,
17 ProviderRespondToUserInputInput,
17 ProviderSendTurnInput,18 ProviderSendTurnInput,
18 ProviderSessionStartInput,19 ProviderSessionStartInput,
19 ProviderStopSessionInput,20 ProviderStopSessionInput,
...@@ -333,6 +334,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -333,6 +334,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
333 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);334 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);
334 });335 });
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
336 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>352 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
337 Effect.gen(function* () {353 Effect.gen(function* () {
338 const input = yield* decodeInputOrValidationError({354 const input = yield* decodeInputOrValidationError({
...@@ -442,6 +458,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -442,6 +458,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
442 sendTurn,458 sendTurn,
443 interruptTurn,459 interruptTurn,
444 respondToRequest,460 respondToRequest,
461 respondToUserInput,
445 stopSession,462 stopSession,
446 listSessions,463 listSessions,
447 getCapabilities,464 getCapabilities,
apps/server/src/provider/Services/ProviderAdapter.ts+10
...@@ -11,6 +11,7 @@ import type {...@@ -11,6 +11,7 @@ import type {
11 ApprovalRequestId,11 ApprovalRequestId,
12 ProviderApprovalDecision,12 ProviderApprovalDecision,
13 ProviderKind,13 ProviderKind,
14 ProviderUserInputAnswers,
14 ProviderRuntimeEvent,15 ProviderRuntimeEvent,
15 ProviderSendTurnInput,16 ProviderSendTurnInput,
16 ProviderSession,17 ProviderSession,
...@@ -79,6 +80,15 @@ export interface ProviderAdapterShape<TError> {...@@ -79,6 +80,15 @@ export interface ProviderAdapterShape<TError> {
79 decision: ProviderApprovalDecision,80 decision: ProviderApprovalDecision,
80 ) => Effect.Effect<void, TError>;81 ) => 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
82 /**92 /**
83 * Stop one provider session.93 * Stop one provider session.
84 */94 */
apps/server/src/provider/Services/ProviderService.ts+8
...@@ -15,6 +15,7 @@ import type {...@@ -15,6 +15,7 @@ import type {
15 ProviderInterruptTurnInput,15 ProviderInterruptTurnInput,
16 ProviderKind,16 ProviderKind,
17 ProviderRespondToRequestInput,17 ProviderRespondToRequestInput,
18 ProviderRespondToUserInputInput,
18 ProviderRuntimeEvent,19 ProviderRuntimeEvent,
19 ProviderSendTurnInput,20 ProviderSendTurnInput,
20 ProviderSession,21 ProviderSession,
...@@ -62,6 +63,13 @@ export interface ProviderServiceShape {...@@ -62,6 +63,13 @@ export interface ProviderServiceShape {
62 input: ProviderRespondToRequestInput,63 input: ProviderRespondToRequestInput,
63 ) => Effect.Effect<void, ProviderServiceError>;64 ) => 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
65 /**73 /**
66 * Stop a provider session.74 * Stop a provider session.
67 */75 */
apps/server/src/wsServer.test.ts+54
...@@ -1126,6 +1126,8 @@ describe("WebSocket Server", () => {...@@ -1126,6 +1126,8 @@ describe("WebSocket Server", () => {
1126 projectId: "project-diff",1126 projectId: "project-diff",
1127 title: "Diff Thread",1127 title: "Diff Thread",
1128 model: "gpt-5-codex",1128 model: "gpt-5-codex",
1129 runtimeMode: "full-access",
1130 interactionMode: "default",
1129 branch: null,1131 branch: null,
1130 worktreePath: null,1132 worktreePath: null,
1131 createdAt,1133 createdAt,
...@@ -1164,6 +1166,7 @@ describe("WebSocket Server", () => {...@@ -1164,6 +1166,7 @@ describe("WebSocket Server", () => {
1164 }),1166 }),
1165 interruptTurn: () => unsupported(),1167 interruptTurn: () => unsupported(),
1166 respondToRequest: () => unsupported(),1168 respondToRequest: () => unsupported(),
1169 respondToUserInput: () => unsupported(),
1167 stopSession: () => unsupported(),1170 stopSession: () => unsupported(),
1168 listSessions: () => Effect.succeed([]),1171 listSessions: () => Effect.succeed([]),
1169 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),1172 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
...@@ -1202,6 +1205,8 @@ describe("WebSocket Server", () => {...@@ -1202,6 +1205,8 @@ describe("WebSocket Server", () => {
1202 projectId: "project-1",1205 projectId: "project-1",
1203 title: "Thread 1",1206 title: "Thread 1",
1204 model: "gpt-5-codex",1207 model: "gpt-5-codex",
1208 runtimeMode: "full-access",
1209 interactionMode: "default",
1205 branch: null,1210 branch: null,
1206 worktreePath: null,1211 worktreePath: null,
1207 createdAt,1212 createdAt,
...@@ -1220,6 +1225,7 @@ describe("WebSocket Server", () => {...@@ -1220,6 +1225,7 @@ describe("WebSocket Server", () => {
1220 },1225 },
1221 assistantDeliveryMode: "streaming",1226 assistantDeliveryMode: "streaming",
1222 runtimeMode: "approval-required",1227 runtimeMode: "approval-required",
1228 interactionMode: "default",
1223 createdAt,1229 createdAt,
1224 });1230 });
1225 expect(startTurnResponse.error).toBeUndefined();1231 expect(startTurnResponse.error).toBeUndefined();
...@@ -1519,6 +1525,54 @@ describe("WebSocket Server", () => {...@@ -1519,6 +1525,54 @@ describe("WebSocket Server", () => {
1519 });1525 });
1520 });1526 });
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
1522 it("routes git core methods over websocket", async () => {1576 it("routes git core methods over websocket", async () => {
1523 const listBranches = vi.fn(() =>1577 const listBranches = vi.fn(() =>
1524 Effect.succeed({1578 Effect.succeed({
apps/server/src/wsServer.ts+70
...@@ -12,6 +12,7 @@ import type { Duplex } from "node:stream";...@@ -12,6 +12,7 @@ import type { Duplex } from "node:stream";
12import Mime from "@effect/platform-node/Mime";12import Mime from "@effect/platform-node/Mime";
13import {13import {
14 CommandId,14 CommandId,
15 DEFAULT_PROVIDER_INTERACTION_MODE,
15 type ClientOrchestrationCommand,16 type ClientOrchestrationCommand,
16 type OrchestrationCommand,17 type OrchestrationCommand,
17 ORCHESTRATION_WS_CHANNELS,18 ORCHESTRATION_WS_CHANNELS,
...@@ -146,6 +147,48 @@ function websocketRawToString(raw: unknown): string | null {...@@ -146,6 +147,48 @@ function websocketRawToString(raw: unknown): string | null {
146 return null;147 return null;
147}148}
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
149function stripRequestTag<T extends { _tag: string }>(body: T) {192function stripRequestTag<T extends { _tag: string }>(body: T) {
150 return Struct.omit(body, ["_tag"]);193 return Struct.omit(body, ["_tag"]);
151}194}
...@@ -595,6 +638,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -595,6 +638,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
595 projectId: bootstrapProjectId,638 projectId: bootstrapProjectId,
596 title: "New thread",639 title: "New thread",
597 model: bootstrapProjectDefaultModel,640 model: bootstrapProjectDefaultModel,
641 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
598 runtimeMode: "full-access",642 runtimeMode: "full-access",
599 branch: null,643 branch: null,
600 worktreePath: null,644 worktreePath: null,
...@@ -684,6 +728,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -684,6 +728,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
684 });728 });
685 }729 }
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
687 case WS_METHODS.shellOpenInEditor: {757 case WS_METHODS.shellOpenInEditor: {
688 const body = stripRequestTag(request.body);758 const body = stripRequestTag(request.body);
689 return yield* openInEditor(body);759 return yield* openInEditor(body);
apps/web/src/components/ChatView.tsx+1245-141
...@@ -41,6 +41,7 @@ import {...@@ -41,6 +41,7 @@ import {
41 useRef,41 useRef,
42 useState,42 useState,
43 useTransition,43 useTransition,
44 useId,
44} from "react";45} from "react";
45import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";46import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
46import { useDebouncedValue } from "@tanstack/react-pacer";47import { useDebouncedValue } from "@tanstack/react-pacer";
...@@ -57,17 +58,22 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer...@@ -57,17 +58,22 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer
57import { isElectron } from "../env";58import { isElectron } from "../env";
58import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";59import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
59import {60import {
61 type ComposerSlashCommand,
60 type ComposerTrigger,62 type ComposerTrigger,
61 type ComposerTriggerKind,63 type ComposerTriggerKind,
62 detectComposerTrigger,64 detectComposerTrigger,
63 expandCollapsedComposerCursor,65 expandCollapsedComposerCursor,
66 parseStandaloneComposerSlashCommand,
64 replaceTextRange,67 replaceTextRange,
65} from "../composer-logic";68} from "../composer-logic";
66import {69import {
67 derivePendingApprovals,70 derivePendingApprovals,
71 derivePendingUserInputs,
68 derivePhase,72 derivePhase,
69 deriveTimelineEntries,73 deriveTimelineEntries,
74 deriveActivePlanState,
70 type PendingApproval,75 type PendingApproval,
76 type PendingUserInput,
71 PROVIDER_OPTIONS,77 PROVIDER_OPTIONS,
72 deriveWorkLogEntries,78 deriveWorkLogEntries,
73 hasToolActivityForTurn,79 hasToolActivityForTurn,
...@@ -76,9 +82,24 @@ import {...@@ -76,9 +82,24 @@ import {
76 formatTimestamp,82 formatTimestamp,
77} from "../session-logic";83} from "../session-logic";
78import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";84import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";
85import {
86 buildPendingUserInputAnswers,
87 derivePendingUserInputProgress,
88 setPendingUserInputCustomAnswer,
89 type PendingUserInputDraftAnswer,
90} from "../pendingUserInput";
79import { useStore } from "../store";91import { useStore } from "../store";
92import {
93 buildPlanImplementationThreadTitle,
94 buildPlanImplementationPrompt,
95 buildProposedPlanMarkdownFilename,
96 findLatestProposedPlanMessage,
97 parseProposedPlanMessage,
98 proposedPlanTitle,
99} from "../proposedPlan";
80import { truncateTitle } from "../truncateTitle";100import { truncateTitle } from "../truncateTitle";
81import {101import {
102 DEFAULT_INTERACTION_MODE,
82 DEFAULT_RUNTIME_MODE,103 DEFAULT_RUNTIME_MODE,
83 DEFAULT_THREAD_TERMINAL_ID,104 DEFAULT_THREAD_TERMINAL_ID,
84 MAX_THREAD_TERMINAL_COUNT,105 MAX_THREAD_TERMINAL_COUNT,
...@@ -114,6 +135,7 @@ import {...@@ -114,6 +135,7 @@ import {
114 FileIcon,135 FileIcon,
115 FolderIcon,136 FolderIcon,
116 DiffIcon,137 DiffIcon,
138 EllipsisIcon,
117 FolderClosedIcon,139 FolderClosedIcon,
118 InfoIcon,140 InfoIcon,
119 LockIcon,141 LockIcon,
...@@ -124,13 +146,8 @@ import {...@@ -124,13 +146,8 @@ import {
124 CheckIcon,146 CheckIcon,
125} from "lucide-react";147} from "lucide-react";
126import { Button } from "./ui/button";148import { Button } from "./ui/button";
127import {149import { Input } from "./ui/input";
128 Select,150import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
129 SelectItem,
130 SelectPopup,
131 SelectTrigger,
132 SelectValue,
133} from "./ui/select";
134import { Separator } from "./ui/separator";151import { Separator } from "./ui/separator";
135import { Group, GroupSeparator } from "./ui/group";152import { Group, GroupSeparator } from "./ui/group";
136import {153import {
...@@ -161,6 +178,16 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";...@@ -161,6 +178,16 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
161import { Badge } from "./ui/badge";178import { Badge } from "./ui/badge";
162import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";179import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
163import { Command, CommandItem, CommandList } from "./ui/command";180import { 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";
164import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";191import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
165import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";192import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
166import {193import {
...@@ -172,7 +199,7 @@ import {...@@ -172,7 +199,7 @@ import {
172} from "~/projectScripts";199} from "~/projectScripts";
173import { Toggle } from "./ui/toggle";200import { Toggle } from "./ui/toggle";
174import { SidebarTrigger } from "./ui/sidebar";201import { SidebarTrigger } from "./ui/sidebar";
175import { newCommandId, newMessageId } from "~/lib/utils";202import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
176import { readNativeApi } from "~/nativeApi";203import { readNativeApi } from "~/nativeApi";
177import { getAppModelOptions, useAppSettings } from "../appSettings";204import { getAppModelOptions, useAppSettings } from "../appSettings";
178import {205import {
...@@ -206,6 +233,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];...@@ -206,6 +233,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
206const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];233const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
207const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];234const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
208const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];235const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
236const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
209const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;237const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
210const SCRIPT_TERMINAL_COLS = 120;238const SCRIPT_TERMINAL_COLS = 120;
211const SCRIPT_TERMINAL_ROWS = 30;239const SCRIPT_TERMINAL_ROWS = 30;
...@@ -236,6 +264,22 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {...@@ -236,6 +264,22 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
236 return "text-muted-foreground/40";264 return "text-muted-foreground/40";
237}265}
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
239interface ExpandedImageItem {283interface ExpandedImageItem {
240 src: string;284 src: string;
241 name: string;285 name: string;
...@@ -279,6 +323,7 @@ function buildLocalDraftThread(...@@ -279,6 +323,7 @@ function buildLocalDraftThread(
279 title: "New thread",323 title: "New thread",
280 model: fallbackModel,324 model: fallbackModel,
281 runtimeMode: draftThread.runtimeMode,325 runtimeMode: draftThread.runtimeMode,
326 interactionMode: draftThread.interactionMode,
282 session: null,327 session: null,
283 messages: [],328 messages: [],
284 error,329 error,
...@@ -336,6 +381,7 @@ type ComposerCommandItem =...@@ -336,6 +381,7 @@ type ComposerCommandItem =
336 | {381 | {
337 id: string;382 id: string;
338 type: "slash-command";383 type: "slash-command";
384 command: ComposerSlashCommand;
339 label: string;385 label: string;
340 description: string;386 description: string;
341 }387 }
...@@ -513,6 +559,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -513,6 +559,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
513 const threads = useStore((store) => store.threads);559 const threads = useStore((store) => store.threads);
514 const projects = useStore((store) => store.projects);560 const projects = useStore((store) => store.projects);
515 const markThreadVisited = useStore((store) => store.markThreadVisited);561 const markThreadVisited = useStore((store) => store.markThreadVisited);
562 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
516 const setStoreThreadError = useStore((store) => store.setError);563 const setStoreThreadError = useStore((store) => store.setError);
517 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);564 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
518 const { settings } = useAppSettings();565 const { settings } = useAppSettings();
...@@ -560,6 +607,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -560,6 +607,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
560 const [isConnecting, _setIsConnecting] = useState(false);607 const [isConnecting, _setIsConnecting] = useState(false);
561 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);608 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
562 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);609 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
610 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
611 ApprovalRequestId[]
612 >([]);
613 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
614 Record<string, Record<string, PendingUserInputDraftAnswer>>
615 >({});
616 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
617 useState<Record<string, number>>({});
563 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});618 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
564 const [nowTick, setNowTick] = useState(() => Date.now());619 const [nowTick, setNowTick] = useState(() => Date.now());
565 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);620 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
...@@ -652,8 +707,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -652,8 +707,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
652 );707 );
653 const activeThread = serverThread ?? localDraftThread;708 const activeThread = serverThread ?? localDraftThread;
654 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;709 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
710 const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
655 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);711 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);
712 const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode);
656 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();713 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();
714 const [isInteractionModePending, startInteractionModeTransition] = useTransition();
657 const isServerThread = serverThread !== undefined;715 const isServerThread = serverThread !== undefined;
658 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;716 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
659 const diffSearch = useMemo(717 const diffSearch = useMemo(
...@@ -688,7 +746,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -688,7 +746,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
688 const selectedProviderByThreadId = composerDraft.provider;746 const selectedProviderByThreadId = composerDraft.provider;
689 const hasThreadStarted = Boolean(747 const hasThreadStarted = Boolean(
690 activeThread &&748 activeThread &&
691 (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null),749 (activeThread.latestTurn !== null ||
750 activeThread.messages.length > 0 ||
751 activeThread.session !== null),
692 );752 );
693 const lockedProvider: ProviderKind | null = hasThreadStarted753 const lockedProvider: ProviderKind | null = hasThreadStarted
694 ? (sessionProvider ?? selectedProviderByThreadId ?? null)754 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
...@@ -700,7 +760,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -700,7 +760,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
700 : null;760 : null;
701 const baseThreadModel = resolveModelSlugForProvider(761 const baseThreadModel = resolveModelSlugForProvider(
702 selectedProvider,762 selectedProvider,
703 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,763 activeThread?.model ??
764 activeProject?.model ??
765 getDefaultModel(selectedProvider) ??
766 DEFAULT_MODEL,
704 );767 );
705 const selectedModel = useMemo(() => {768 const selectedModel = useMemo(() => {
706 const draftModel = composerDraft.model;769 const draftModel = composerDraft.model;
...@@ -716,7 +779,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -716,7 +779,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
716779
717 const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider);780 const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider);
718 if (normalizedDraftModel) {781 if (normalizedDraftModel) {
719 const normalizedMatch = providerOptions.find((option) => option.slug === normalizedDraftModel);782 const normalizedMatch = providerOptions.find(
783 (option) => option.slug === normalizedDraftModel,
784 );
720 if (normalizedMatch) {785 if (normalizedMatch) {
721 return normalizedMatch.slug as ModelSlug;786 return normalizedMatch.slug as ModelSlug;
722 }787 }
...@@ -732,19 +797,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -732,19 +797,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
732 [selectedModel, selectedProvider],797 [selectedModel, selectedProvider],
733 );798 );
734 const selectedCursorModelCapabilities = useMemo(799 const selectedCursorModelCapabilities = useMemo(
735 () =>800 () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null),
736 selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null,
737 [selectedCursorModel],801 [selectedCursorModel],
738 );802 );
739 const hasSelectedCursorTraits = Boolean(803 const hasSelectedCursorTraits = Boolean(
740 selectedCursorModelCapabilities &&804 selectedCursorModelCapabilities &&
741 (selectedCursorModelCapabilities.supportsReasoning ||805 (selectedCursorModelCapabilities.supportsReasoning ||
742 selectedCursorModelCapabilities.supportsFast ||806 selectedCursorModelCapabilities.supportsFast ||
743 selectedCursorModelCapabilities.supportsThinking),807 selectedCursorModelCapabilities.supportsThinking),
744 );808 );
745 const selectedModelForPicker =809 const selectedModelForPicker =
746 selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel;810 selectedProvider === "cursor" && selectedCursorModel
747 const modelOptionsByProvider = useMemo(() => getCustomModelOptionsByProvider(settings), [settings]);811 ? selectedCursorModel.family
812 : selectedModel;
813 const modelOptionsByProvider = useMemo(
814 () => getCustomModelOptionsByProvider(settings),
815 [settings],
816 );
748 const selectedModelForPickerWithCustomFallback = useMemo(() => {817 const selectedModelForPickerWithCustomFallback = useMemo(() => {
749 if (selectedProvider !== "cursor") {818 if (selectedProvider !== "cursor") {
750 const currentOptions = modelOptionsByProvider[selectedProvider];819 const currentOptions = modelOptionsByProvider[selectedProvider];
...@@ -794,6 +863,80 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -794,6 +863,80 @@ export default function ChatView({ threadId }: ChatViewProps) {
794 () => derivePendingApprovals(threadActivities),863 () => derivePendingApprovals(threadActivities),
795 [threadActivities],864 [threadActivities],
796 );865 );
866 const pendingUserInputs = useMemo(
867 () => derivePendingUserInputs(threadActivities),
868 [threadActivities],
869 );
870 const activePendingUserInput = pendingUserInputs[0] ?? null;
871 const activePendingDraftAnswers = useMemo(
872 () =>
873 activePendingUserInput
874 ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ??
875 EMPTY_PENDING_USER_INPUT_ANSWERS)
876 : EMPTY_PENDING_USER_INPUT_ANSWERS,
877 [activePendingUserInput, pendingUserInputAnswersByRequestId],
878 );
879 const activePendingQuestionIndex = activePendingUserInput
880 ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0)
881 : 0;
882 const activePendingProgress = useMemo(
883 () =>
884 activePendingUserInput
885 ? derivePendingUserInputProgress(
886 activePendingUserInput.questions,
887 activePendingDraftAnswers,
888 activePendingQuestionIndex,
889 )
890 : null,
891 [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput],
892 );
893 const activePendingResolvedAnswers = useMemo(
894 () =>
895 activePendingUserInput
896 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers)
897 : null,
898 [activePendingDraftAnswers, activePendingUserInput],
899 );
900 const activePendingIsResponding = activePendingUserInput
901 ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
902 : false;
903 const activeProposedPlan = useMemo(() => {
904 if (!latestTurnSettled) {
905 return null;
906 }
907 return findLatestProposedPlanMessage(
908 activeThread?.messages ?? [],
909 activeLatestTurn?.assistantMessageId ?? null,
910 );
911 }, [activeLatestTurn?.assistantMessageId, activeThread?.messages, latestTurnSettled]);
912 const activePlan = useMemo(
913 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
914 [activeLatestTurn?.turnId, threadActivities],
915 );
916 const showPlanFollowUpPrompt =
917 pendingUserInputs.length === 0 &&
918 optimisticInteractionMode === "plan" &&
919 latestTurnSettled &&
920 activeProposedPlan !== null;
921 const hasComposerHeader =
922 pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null);
923 useEffect(() => {
924 if (!activePendingProgress) {
925 return;
926 }
927 promptRef.current = activePendingProgress.customAnswer;
928 setComposerCursor(activePendingProgress.customAnswer.length);
929 setComposerTrigger(
930 detectComposerTrigger(
931 activePendingProgress.customAnswer,
932 expandCollapsedComposerCursor(
933 activePendingProgress.customAnswer,
934 activePendingProgress.customAnswer.length,
935 ),
936 ),
937 );
938 setComposerHighlightedItemId(null);
939 }, [activePendingProgress, activePendingUserInput?.requestId]);
797 useEffect(() => {940 useEffect(() => {
798 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;941 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
799 }, [attachmentPreviewHandoffByMessageId]);942 }, [attachmentPreviewHandoffByMessageId]);
...@@ -1038,31 +1181,54 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1038,31 +1181,54 @@ export default function ChatView({ threadId }: ChatViewProps) {
1038 }1181 }
10391182
1040 if (composerTrigger.kind === "slash-command") {1183 if (composerTrigger.kind === "slash-command") {
1041 if (!"model".includes(composerTrigger.query.toLowerCase())) {1184 const slashCommandItems = [
1042 return [];
1043 }
1044 return [
1045 {1185 {
1046 id: "slash:model",1186 id: "slash:model",
1047 type: "slash-command",1187 type: "slash-command",
1188 command: "model",
1048 label: "/model",1189 label: "/model",
1049 description: "Switch response model for this thread",1190 description: "Switch response model for this thread",
1050 },1191 },
1051 ];1192 {
1193 id: "slash:plan",
1194 type: "slash-command",
1195 command: "plan",
1196 label: "/plan",
1197 description: "Switch this thread into plan mode",
1198 },
1199 {
1200 id: "slash:default",
1201 type: "slash-command",
1202 command: "default",
1203 label: "/default",
1204 description: "Switch this thread back to normal chat mode",
1205 },
1206 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
1207 const query = composerTrigger.query.trim().toLowerCase();
1208 if (!query) {
1209 return [...slashCommandItems];
1210 }
1211 return slashCommandItems.filter(
1212 (item) => item.command.includes(query) || item.label.slice(1).includes(query),
1213 );
1052 }1214 }
10531215
1054 return searchableModelOptions.filter(({ searchSlug, searchName, searchProvider }) => {1216 return searchableModelOptions
1055 const query = composerTrigger.query.trim().toLowerCase();1217 .filter(({ searchSlug, searchName, searchProvider }) => {
1056 if (!query) return true;1218 const query = composerTrigger.query.trim().toLowerCase();
1057 return searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query);1219 if (!query) return true;
1058 }).map(({ provider, providerLabel, slug, name }) => ({1220 return (
1059 id: `model:${provider}:${slug}`,1221 searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query)
1060 type: "model",1222 );
1061 provider,1223 })
1062 model: slug,1224 .map(({ provider, providerLabel, slug, name }) => ({
1063 label: name,1225 id: `model:${provider}:${slug}`,
1064 description: `${providerLabel} · ${slug}`,1226 type: "model",
1065 }));1227 provider,
1228 model: slug,
1229 label: name,
1230 description: `${providerLabel} · ${slug}`,
1231 }));
1066 }, [composerTrigger, searchableModelOptions, workspaceEntries]);1232 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1067 const composerMenuOpen = Boolean(composerTrigger);1233 const composerMenuOpen = Boolean(composerTrigger);
1068 const activeComposerMenuItem = useMemo(1234 const activeComposerMenuItem = useMemo(
...@@ -1444,6 +1610,41 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1444,6 +1610,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
1444 });1610 });
1445 };1611 };
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
1447 useEffect(() => {1648 useEffect(() => {
1448 try {1649 try {
1449 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {1650 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
...@@ -2070,7 +2271,39 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2070,7 +2271,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
2070 e?.preventDefault();2271 e?.preventDefault();
2071 const api = readNativeApi();2272 const api = readNativeApi();
2072 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;2273 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
2274 if (activePendingProgress) {
2275 onAdvanceActivePendingUserInput();
2276 return;
2277 }
2073 const trimmed = prompt.trim();2278 const trimmed = prompt.trim();
2279 if (showPlanFollowUpPrompt && activeProposedPlan) {
2280 const followUpText =
2281 trimmed.length > 0
2282 ? trimmed
2283 : buildPlanImplementationPrompt(activeProposedPlan.plan.planMarkdown);
2284 const nextInteractionMode = trimmed.length > 0 ? "plan" : "default";
2285 promptRef.current = "";
2286 clearComposerDraftContent(activeThread.id);
2287 setComposerHighlightedItemId(null);
2288 setComposerCursor(0);
2289 setComposerTrigger(null);
2290 await onSubmitPlanFollowUp({
2291 text: followUpText,
2292 interactionMode: nextInteractionMode,
2293 });
2294 return;
2295 }
2296 const standaloneSlashCommand =
2297 composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null;
2298 if (standaloneSlashCommand) {
2299 await handleInteractionModeChange(standaloneSlashCommand);
2300 promptRef.current = "";
2301 clearComposerDraftContent(activeThread.id);
2302 setComposerHighlightedItemId(null);
2303 setComposerCursor(0);
2304 setComposerTrigger(null);
2305 return;
2306 }
2074 if (!trimmed && composerImages.length === 0) return;2307 if (!trimmed && composerImages.length === 0) return;
2075 if (!activeProject) return;2308 if (!activeProject) return;
2076 const threadIdForSend = activeThread.id;2309 const threadIdForSend = activeThread.id;
...@@ -2183,7 +2416,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2183,7 +2416,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2183 }2416 }
2184 }2417 }
2185 const title = truncateTitle(titleSeed);2418 const title = truncateTitle(titleSeed);
2186 let threadCreateModel: ModelSlug = selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL;2419 let threadCreateModel: ModelSlug =
2420 selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL;
21872421
2188 if (isLocalDraftThread) {2422 if (isLocalDraftThread) {
2189 await api.orchestration.dispatchCommand({2423 await api.orchestration.dispatchCommand({
...@@ -2194,6 +2428,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2194,6 +2428,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2194 title,2428 title,
2195 model: threadCreateModel,2429 model: threadCreateModel,
2196 runtimeMode,2430 runtimeMode,
2431 interactionMode,
2197 branch: nextThreadBranch,2432 branch: nextThreadBranch,
2198 worktreePath: nextThreadWorktreePath,2433 worktreePath: nextThreadWorktreePath,
2199 createdAt: activeThread.createdAt,2434 createdAt: activeThread.createdAt,
...@@ -2254,6 +2489,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2254,6 +2489,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2254 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),2489 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),
2255 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2490 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2256 runtimeMode,2491 runtimeMode,
2492 interactionMode,
2257 createdAt: messageCreatedAt,2493 createdAt: messageCreatedAt,
2258 });2494 });
2259 turnStartSucceeded = true;2495 turnStartSucceeded = true;
...@@ -2337,6 +2573,345 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2337,6 +2573,345 @@ export default function ChatView({ threadId }: ChatViewProps) {
2337 [activeThreadId, setStoreThreadError],2573 [activeThreadId, setStoreThreadError],
2338 );2574 );
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
2340 const onProviderModelSelect = useCallback(2915 const onProviderModelSelect = useCallback(
2341 (provider: ProviderKind, model: ModelSlug) => {2916 (provider: ProviderKind, model: ModelSlug) => {
2342 if (!activeThread) return;2917 if (!activeThread) return;
...@@ -2452,7 +3027,21 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2452,7 +3027,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
2452 }3027 }
2453 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);3028 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
2454 promptRef.current = next.text;3029 promptRef.current = next.text;
2455 setPrompt(next.text);3030 const activePendingQuestion = activePendingProgress?.activeQuestion;
3031 if (activePendingQuestion && activePendingUserInput) {
3032 setPendingUserInputAnswersByRequestId((existing) => ({
3033 ...existing,
3034 [activePendingUserInput.requestId]: {
3035 ...existing[activePendingUserInput.requestId],
3036 [activePendingQuestion.id]: setPendingUserInputCustomAnswer(
3037 existing[activePendingUserInput.requestId]?.[activePendingQuestion.id],
3038 next.text,
3039 ),
3040 },
3041 }));
3042 } else {
3043 setPrompt(next.text);
3044 }
2456 setComposerCursor(next.cursor);3045 setComposerCursor(next.cursor);
2457 setComposerTrigger(detectComposerTrigger(next.text, next.cursor));3046 setComposerTrigger(detectComposerTrigger(next.text, next.cursor));
2458 window.requestAnimationFrame(() => {3047 window.requestAnimationFrame(() => {
...@@ -2460,7 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2460,7 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2460 });3049 });
2461 return true;3050 return true;
2462 },3051 },
2463 [setPrompt],3052 [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt],
2464 );3053 );
24653054
2466 const readComposerSnapshot = useCallback((): { value: string; cursor: number } => {3055 const readComposerSnapshot = useCallback((): { value: string; cursor: number } => {
...@@ -2506,7 +3095,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2506,7 +3095,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
2506 return;3095 return;
2507 }3096 }
2508 if (item.type === "slash-command") {3097 if (item.type === "slash-command") {
2509 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", {3098 if (item.command === "model") {
3099 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", {
3100 expectedText: expectedToken,
3101 });
3102 if (applied) {
3103 setComposerHighlightedItemId(null);
3104 }
3105 return;
3106 }
3107 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
3108 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
2510 expectedText: expectedToken,3109 expectedText: expectedToken,
2511 });3110 });
2512 if (applied) {3111 if (applied) {
...@@ -2522,7 +3121,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2522,7 +3121,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2522 setComposerHighlightedItemId(null);3121 setComposerHighlightedItemId(null);
2523 }3122 }
2524 },3123 },
2525 [applyPromptReplacement, onProviderModelSelect, resolveActiveComposerTrigger],3124 [
3125 applyPromptReplacement,
3126 handleInteractionModeChange,
3127 onProviderModelSelect,
3128 resolveActiveComposerTrigger,
3129 ],
2526 );3130 );
2527 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {3131 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
2528 setComposerHighlightedItemId(itemId);3132 setComposerHighlightedItemId(itemId);
...@@ -2553,6 +3157,15 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2553,6 +3157,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
25533157
2554 const onPromptChange = useCallback(3158 const onPromptChange = useCallback(
2555 (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => {3159 (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => {
3160 if (activePendingProgress?.activeQuestion && activePendingUserInput) {
3161 onChangeActivePendingUserInputCustomAnswer(
3162 activePendingProgress.activeQuestion.id,
3163 nextPrompt,
3164 nextCursor,
3165 cursorAdjacentToMention,
3166 );
3167 return;
3168 }
2556 promptRef.current = nextPrompt;3169 promptRef.current = nextPrompt;
2557 setPrompt(nextPrompt);3170 setPrompt(nextPrompt);
2558 setComposerCursor(nextCursor);3171 setComposerCursor(nextCursor);
...@@ -2565,7 +3178,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2565,7 +3178,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2565 ),3178 ),
2566 );3179 );
2567 },3180 },
2568 [setPrompt],3181 [
3182 activePendingProgress?.activeQuestion,
3183 activePendingUserInput,
3184 onChangeActivePendingUserInputCustomAnswer,
3185 setPrompt,
3186 ],
2569 );3187 );
25703188
2571 const onComposerCommandKey = (3189 const onComposerCommandKey = (
...@@ -2698,6 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2698,6 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2698 respondingRequestIds={respondingRequestIds}3316 respondingRequestIds={respondingRequestIds}
2699 onRespondToApproval={onRespondToApproval}3317 onRespondToApproval={onRespondToApproval}
2700 />3318 />
3319 <PlanModePanel activePlan={activePlan} />
27013320
2702 {/* Messages */}3321 {/* Messages */}
2703 <div3322 <div
...@@ -2734,6 +3353,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2734,6 +3353,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2734 onImageExpand={onExpandTimelineImage}3353 onImageExpand={onExpandTimelineImage}
2735 markdownCwd={gitCwd ?? undefined}3354 markdownCwd={gitCwd ?? undefined}
2736 resolvedTheme={resolvedTheme}3355 resolvedTheme={resolvedTheme}
3356 workspaceRoot={activeProject?.cwd ?? undefined}
2737 />3357 />
2738 </div>3358 </div>
27393359
...@@ -2754,8 +3374,32 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2754,8 +3374,32 @@ export default function ChatView({ threadId }: ChatViewProps) {
2754 onDragLeave={onComposerDragLeave}3374 onDragLeave={onComposerDragLeave}
2755 onDrop={onComposerDrop}3375 onDrop={onComposerDrop}
2756 >3376 >
3377 {pendingUserInputs.length > 0 ? (
3378 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3379 <ComposerPendingUserInputPanel
3380 pendingUserInputs={pendingUserInputs}
3381 respondingRequestIds={respondingUserInputRequestIds}
3382 answers={activePendingDraftAnswers}
3383 questionIndex={activePendingQuestionIndex}
3384 onSelectOption={onSelectActivePendingUserInputOption}
3385 />
3386 </div>
3387 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
3388 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3389 <ComposerPlanFollowUpBanner
3390 key={activeProposedPlan.message.id}
3391 planTitle={proposedPlanTitle(activeProposedPlan.plan.planMarkdown) ?? null}
3392 />
3393 </div>
3394 ) : null}
3395
2757 {/* Textarea area */}3396 {/* Textarea area */}
2758 <div className="relative px-3 pt-3.5 pb-2 sm:px-4 sm:pt-4">3397 <div
3398 className={cn(
3399 "relative px-3 pb-2 sm:px-4",
3400 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
3401 )}
3402 >
2759 {composerMenuOpen && (3403 {composerMenuOpen && (
2760 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">3404 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
2761 <ComposerCommandMenu3405 <ComposerCommandMenu
...@@ -2770,7 +3414,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2770,7 +3414,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2770 </div>3414 </div>
2771 )}3415 )}
27723416
2773 {composerImages.length > 0 && (3417 {pendingUserInputs.length === 0 && composerImages.length > 0 && (
2774 <div className="mb-3 flex flex-wrap gap-2">3418 <div className="mb-3 flex flex-wrap gap-2">
2775 {composerImages.map((image) => (3419 {composerImages.map((image) => (
2776 <div3420 <div
...@@ -2836,15 +3480,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2836,15 +3480,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
2836 )}3480 )}
2837 <ComposerPromptEditor3481 <ComposerPromptEditor
2838 ref={composerEditorRef}3482 ref={composerEditorRef}
2839 value={prompt}3483 value={activePendingProgress ? activePendingProgress.customAnswer : prompt}
2840 cursor={composerCursor}3484 cursor={composerCursor}
2841 onChange={onPromptChange}3485 onChange={onPromptChange}
2842 onCommandKeyDown={onComposerCommandKey}3486 onCommandKeyDown={onComposerCommandKey}
2843 onPaste={onComposerPaste}3487 onPaste={onComposerPaste}
2844 placeholder={3488 placeholder={
2845 phase === "disconnected"3489 activePendingProgress
2846 ? "Ask for follow-up changes or attach images"3490 ? "Type your own answer, or leave this blank to use the selected option"
2847 : "Ask anything, @tag files/folders, or use /model"3491 : showPlanFollowUpPrompt && activeProposedPlan
3492 ? "Add feedback to refine the plan, or leave this blank to implement it"
3493 : phase === "disconnected"
3494 ? "Ask for follow-up changes or attach images"
3495 : "Ask anything, @tag files/folders, or use /model"
2848 }3496 }
2849 disabled={isConnecting}3497 disabled={isConnecting}
2850 />3498 />
...@@ -2890,39 +3538,44 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2890,39 +3538,44 @@ export default function ChatView({ threadId }: ChatViewProps) {
2890 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3538 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2891 )}3539 )}
28923540
2893 {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && (3541 {selectedCursorModel &&
2894 <>3542 selectedCursorModelCapabilities &&
2895 {cursorModelSelectionLockedReason ? (3543 hasSelectedCursorTraits && (
2896 <Tooltip>3544 <>
2897 <TooltipTrigger3545 {cursorModelSelectionLockedReason ? (
2898 render={3546 <Tooltip>
2899 <span className="inline-flex">3547 <TooltipTrigger
2900 <CursorTraitsPicker3548 render={
2901 selection={selectedCursorModel}3549 <span className="inline-flex">
2902 capabilities={selectedCursorModelCapabilities}3550 <CursorTraitsPicker
2903 disabled3551 selection={selectedCursorModel}
2904 onReasoningChange={onCursorReasoningSelect}3552 capabilities={selectedCursorModelCapabilities}
2905 onFastModeChange={onCursorFastModeChange}3553 disabled
2906 onThinkingModeChange={onCursorThinkingModeChange}3554 onReasoningChange={onCursorReasoningSelect}
2907 />3555 onFastModeChange={onCursorFastModeChange}
2908 </span>3556 onThinkingModeChange={onCursorThinkingModeChange}
2909 }3557 />
3558 </span>
3559 }
3560 />
3561 <TooltipPopup
3562 side="top"
3563 className="max-w-72 whitespace-normal leading-tight"
3564 >
3565 {cursorModelSelectionLockedReason}
3566 </TooltipPopup>
3567 </Tooltip>
3568 ) : (
3569 <CursorTraitsPicker
3570 selection={selectedCursorModel}
3571 capabilities={selectedCursorModelCapabilities}
3572 onReasoningChange={onCursorReasoningSelect}
3573 onFastModeChange={onCursorFastModeChange}
3574 onThinkingModeChange={onCursorThinkingModeChange}
2910 />3575 />
2911 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-tight">3576 )}
2912 {cursorModelSelectionLockedReason}3577 </>
2913 </TooltipPopup>3578 )}
2914 </Tooltip>
2915 ) : (
2916 <CursorTraitsPicker
2917 selection={selectedCursorModel}
2918 capabilities={selectedCursorModelCapabilities}
2919 onReasoningChange={onCursorReasoningSelect}
2920 onFastModeChange={onCursorFastModeChange}
2921 onThinkingModeChange={onCursorThinkingModeChange}
2922 />
2923 )}
2924 </>
2925 )}
2926 </>3579 </>
2927 ) : supportsReasoningEffort ? (3580 ) : supportsReasoningEffort ? (
2928 <>3581 <>
...@@ -2943,6 +3596,33 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2943,6 +3596,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
2943 {/* Divider */}3596 {/* Divider */}
2944 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3597 <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
2946 {/* Runtime mode toggle */}3626 {/* Runtime mode toggle */}
2947 <Button3627 <Button
2948 variant="ghost"3628 variant="ghost"
...@@ -2952,9 +3632,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2952,9 +3632,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2952 disabled={isRuntimeModePending}3632 disabled={isRuntimeModePending}
2953 onClick={() =>3633 onClick={() =>
2954 void handleRuntimeModeChange(3634 void handleRuntimeModeChange(
2955 optimisticRuntimeMode === "full-access"3635 optimisticRuntimeMode === "full-access" ? "approval-required" : "full-access",
2956 ? "approval-required"
2957 : "full-access",
2958 )3636 )
2959 }3637 }
2960 title={3638 title={
...@@ -2975,10 +3653,41 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2975,10 +3653,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
2975 {isPreparingWorktree ? (3653 {isPreparingWorktree ? (
2976 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>3654 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
2977 ) : null}3655 ) : null}
2978 {phase === "running" ? (3656 {activePendingProgress ? (
3657 <div className="flex items-center gap-2">
3658 {activePendingProgress.questionIndex > 0 ? (
3659 <Button
3660 size="sm"
3661 variant="outline"
3662 className="rounded-full"
3663 onClick={onPreviousActivePendingUserInputQuestion}
3664 disabled={activePendingIsResponding}
3665 >
3666 Previous
3667 </Button>
3668 ) : null}
3669 <Button
3670 type="submit"
3671 size="sm"
3672 className="rounded-full px-4"
3673 disabled={
3674 activePendingIsResponding ||
3675 (activePendingProgress.isLastQuestion
3676 ? !activePendingResolvedAnswers
3677 : !activePendingProgress.canAdvance)
3678 }
3679 >
3680 {activePendingIsResponding
3681 ? "Submitting..."
3682 : activePendingProgress.isLastQuestion
3683 ? "Submit answers"
3684 : "Next question"}
3685 </Button>
3686 </div>
3687 ) : phase === "running" ? (
2979 <button3688 <button
2980 type="button"3689 type="button"
2981 className="flex h-9 w-9 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"3690 className="flex size-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
2982 onClick={() => void onInterrupt()}3691 onClick={() => void onInterrupt()}
2983 aria-label="Stop generation"3692 aria-label="Stop generation"
2984 >3693 >
...@@ -2992,61 +3701,110 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2992,61 +3701,110 @@ export default function ChatView({ threadId }: ChatViewProps) {
2992 <rect x="2" y="2" width="8" height="8" rx="1.5" />3701 <rect x="2" y="2" width="8" height="8" rx="1.5" />
2993 </svg>3702 </svg>
2994 </button>3703 </button>
2995 ) : (3704 ) : pendingUserInputs.length === 0 ? (
2996 <button3705 showPlanFollowUpPrompt ? (
2997 type="submit"3706 prompt.trim().length > 0 ? (
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"3707 <Button
2999 disabled={3708 type="submit"
3000 isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)3709 size="sm"
3001 }3710 className="h-9 rounded-full px-4 sm:h-8"
3002 aria-label={3711 disabled={isSendBusy || isConnecting}
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"
3020 >3712 >
3021 <circle3713 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
3022 cx="7"3714 </Button>
3023 cy="7"
3024 r="5.5"
3025 stroke="currentColor"
3026 strokeWidth="1.5"
3027 strokeLinecap="round"
3028 strokeDasharray="20 12"
3029 />
3030 </svg>
3031 ) : (3715 ) : (
3032 <svg3716 <div className="flex items-center">
3033 width="14"3717 <Button
3034 height="14"3718 type="submit"
3035 viewBox="0 0 14 14"3719 size="sm"
3036 fill="none"3720 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
3037 aria-hidden="true"3721 disabled={isSendBusy || isConnecting}
3038 >3722 >
3039 <path3723 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
3040 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"3724 </Button>
3041 stroke="currentColor"3725 <Menu>
3042 strokeWidth="1.8"3726 <MenuTrigger
3043 strokeLinecap="round"3727 render={
3044 strokeLinejoin="round"3728 <Button
3045 />3729 size="sm"
3046 </svg>3730 variant="default"
3047 )}3731 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
3048 </button>3732 aria-label="Implementation actions"
3049 )}3733 disabled={isSendBusy || isConnecting}
3734 />
3735 }
3736 >
3737 <ChevronDownIcon className="size-3.5" />
3738 </MenuTrigger>
3739 <MenuPopup align="end" side="top">
3740 <MenuItem
3741 disabled={isSendBusy || isConnecting}
3742 onClick={() => void onImplementPlanInNewThread()}
3743 >
3744 Implement in new thread
3745 </MenuItem>
3746 </MenuPopup>
3747 </Menu>
3748 </div>
3749 )
3750 ) : (
3751 <button
3752 type="submit"
3753 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
3754 disabled={
3755 isSendBusy ||
3756 isConnecting ||
3757 (!prompt.trim() && composerImages.length === 0)
3758 }
3759 aria-label={
3760 isConnecting
3761 ? "Connecting"
3762 : isPreparingWorktree
3763 ? "Preparing worktree"
3764 : isSendBusy
3765 ? "Sending"
3766 : "Send message"
3767 }
3768 >
3769 {isConnecting || isSendBusy ? (
3770 <svg
3771 width="14"
3772 height="14"
3773 viewBox="0 0 14 14"
3774 fill="none"
3775 className="animate-spin"
3776 aria-hidden="true"
3777 >
3778 <circle
3779 cx="7"
3780 cy="7"
3781 r="5.5"
3782 stroke="currentColor"
3783 strokeWidth="1.5"
3784 strokeLinecap="round"
3785 strokeDasharray="20 12"
3786 />
3787 </svg>
3788 ) : (
3789 <svg
3790 width="14"
3791 height="14"
3792 viewBox="0 0 14 14"
3793 fill="none"
3794 aria-hidden="true"
3795 >
3796 <path
3797 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
3798 stroke="currentColor"
3799 strokeWidth="1.8"
3800 strokeLinecap="round"
3801 strokeLinejoin="round"
3802 />
3803 </svg>
3804 )}
3805 </button>
3806 )
3807 ) : null}
3050 </div>3808 </div>
3051 </div>3809 </div>
3052 </div>3810 </div>
...@@ -3326,7 +4084,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({...@@ -3326,7 +4084,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
3326 ? "Command approval requested"4084 ? "Command approval requested"
3327 : approval.requestKind === "file-read"4085 : approval.requestKind === "file-read"
3328 ? "File-read approval requested"4086 ? "File-read approval requested"
3329 : "File-change approval requested"}4087 : "File-change approval requested"}
3330 </AlertTitle>4088 </AlertTitle>
3331 <AlertDescription4089 <AlertDescription
3332 className="truncate block font-mono text-[11px]"4090 className="truncate block font-mono text-[11px]"
...@@ -3375,6 +4133,159 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({...@@ -3375,6 +4133,159 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
3375 );4133 );
3376});4134});
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
3378const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {4289const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
3379 const [copied, setCopied] = useState(false);4290 const [copied, setCopied] = useState(false);
33804291
...@@ -3537,6 +4448,191 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -3537,6 +4448,191 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
3537 return (4448 return (
3538 <div className="space-y-0.5">4449 <div className="space-y-0.5">
3539 {treeNodes.map((node) => renderTreeNode(node, 0))}4450 {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}
3540 </div>4636 </div>
3541 );4637 );
3542});4638});
...@@ -3561,6 +4657,7 @@ interface MessagesTimelineProps {...@@ -3561,6 +4657,7 @@ interface MessagesTimelineProps {
3561 onImageExpand: (preview: ExpandedImagePreview) => void;4657 onImageExpand: (preview: ExpandedImagePreview) => void;
3562 markdownCwd: string | undefined;4658 markdownCwd: string | undefined;
3563 resolvedTheme: "light" | "dark";4659 resolvedTheme: "light" | "dark";
4660 workspaceRoot: string | undefined;
3564}4661}
35654662
3566type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];4663type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
...@@ -3602,6 +4699,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3602,6 +4699,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
3602 onImageExpand,4699 onImageExpand,
3603 markdownCwd,4700 markdownCwd,
3604 resolvedTheme,4701 resolvedTheme,
4702 workspaceRoot,
3605}: MessagesTimelineProps) {4703}: MessagesTimelineProps) {
3606 const timelineRootRef = useRef<HTMLDivElement | null>(null);4704 const timelineRootRef = useRef<HTMLDivElement | null>(null);
3607 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);4705 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
...@@ -3946,9 +5044,10 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3946,9 +5044,10 @@ const MessagesTimeline = memo(function MessagesTimeline({
3946 </div>5044 </div>
3947 )}5045 )}
3948 <div className="min-w-0 px-1 py-0.5">5046 <div className="min-w-0 px-1 py-0.5">
3949 <ChatMarkdown5047 <ProposedPlanMessage
3950 text={messageText}5048 text={messageText}
3951 cwd={markdownCwd}5049 cwd={markdownCwd}
5050 workspaceRoot={workspaceRoot}
3952 isStreaming={Boolean(row.message.streaming)}5051 isStreaming={Boolean(row.message.streaming)}
3953 />5052 />
3954 {(() => {5053 {(() => {
...@@ -4201,7 +5300,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -4201,7 +5300,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4201 return (5300 return (
4202 <MenuSub key={option.value}>5301 <MenuSub key={option.value}>
4203 <MenuSubTrigger disabled={isDisabledByProviderLock}>5302 <MenuSubTrigger disabled={isDisabledByProviderLock}>
4204 <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" />5303 <OptionIcon
5304 aria-hidden="true"
5305 className="size-4 shrink-0 text-muted-foreground/85"
5306 />
4205 {option.label}5307 {option.label}
4206 </MenuSubTrigger>5308 </MenuSubTrigger>
4207 <MenuSubPopup className="[--available-height:min(24rem,70vh)]">5309 <MenuSubPopup className="[--available-height:min(24rem,70vh)]">
...@@ -4354,7 +5456,9 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -4354,7 +5456,9 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
4354 </MenuGroup>5456 </MenuGroup>
4355 )}5457 )}
4356 {props.capabilities.supportsReasoning &&5458 {props.capabilities.supportsReasoning &&
4357 (props.capabilities.supportsFast || props.capabilities.supportsThinking) && <MenuDivider />}5459 (props.capabilities.supportsFast || props.capabilities.supportsThinking) && (
5460 <MenuDivider />
5461 )}
4358 {props.capabilities.supportsFast && (5462 {props.capabilities.supportsFast && (
4359 <MenuGroup>5463 <MenuGroup>
4360 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>5464 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
apps/web/src/composer-logic.test.ts+27
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 detectComposerTrigger,4 detectComposerTrigger,
5 expandCollapsedComposerCursor,5 expandCollapsedComposerCursor,
6 isCollapsedCursorAdjacentToMention,6 isCollapsedCursorAdjacentToMention,
7 parseStandaloneComposerSlashCommand,
7 replaceTextRange,8 replaceTextRange,
8} from "./composer-logic";9} from "./composer-logic";
910
...@@ -43,6 +44,18 @@ describe("detectComposerTrigger", () => {...@@ -43,6 +44,18 @@ describe("detectComposerTrigger", () => {
43 rangeEnd: text.length,44 rangeEnd: text.length,
44 });45 });
45 });46 });
47
48 it("detects non-model slash commands while typing", () => {
49 const text = "/pl";
50 const trigger = detectComposerTrigger(text, text.length);
51
52 expect(trigger).toEqual({
53 kind: "slash-command",
54 query: "pl",
55 rangeStart: 0,
56 rangeEnd: text.length,
57 });
58 });
46});59});
4760
48describe("replaceTextRange", () => {61describe("replaceTextRange", () => {
...@@ -111,3 +124,17 @@ describe("isCollapsedCursorAdjacentToMention", () => {...@@ -111,3 +124,17 @@ describe("isCollapsedCursorAdjacentToMention", () => {
111 expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false);124 expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false);
112 });125 });
113});126});
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 @@...@@ -1,6 +1,7 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22
3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default";
45
5export interface ComposerTrigger {6export interface ComposerTrigger {
6 kind: ComposerTriggerKind;7 kind: ComposerTriggerKind;
...@@ -9,6 +10,8 @@ export interface ComposerTrigger {...@@ -9,6 +10,8 @@ export interface ComposerTrigger {
9 rangeEnd: number;10 rangeEnd: number;
10}11}
1112
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
14
12function clampCursor(text: string, cursor: number): number {15function clampCursor(text: string, cursor: number): number {
13 if (!Number.isFinite(cursor)) return text.length;16 if (!Number.isFinite(cursor)) return text.length;
14 return Math.max(0, Math.min(text.length, Math.floor(cursor)));17 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
...@@ -121,7 +124,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -121,7 +124,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
121 rangeEnd: cursor,124 rangeEnd: cursor,
122 };125 };
123 }126 }
124 if ("model".startsWith(commandQuery.toLowerCase())) {127 if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) {
125 return {128 return {
126 kind: "slash-command",129 kind: "slash-command",
127 query: commandQuery,130 query: commandQuery,
...@@ -157,6 +160,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -157,6 +160,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
157 };160 };
158}161}
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
160export function replaceTextRange(174export function replaceTextRange(
161 text: string,175 text: string,
162 rangeStart: number,176 rangeStart: number,
apps/web/src/composerDraftStore.ts+23-1
...@@ -6,9 +6,14 @@ import {...@@ -6,9 +6,14 @@ import {
6 normalizeModelSlug,6 normalizeModelSlug,
7 type CodexReasoningEffort,7 type CodexReasoningEffort,
8 type ProviderKind,8 type ProviderKind,
9 type ProviderInteractionMode,
9 type RuntimeMode,10 type RuntimeMode,
10} from "@t3tools/contracts";11} 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";
12import { create } from "zustand";17import { create } from "zustand";
13import { createJSONStorage, persist } from "zustand/middleware";18import { createJSONStorage, persist } from "zustand/middleware";
1419
...@@ -40,6 +45,7 @@ interface PersistedDraftThreadState {...@@ -40,6 +45,7 @@ interface PersistedDraftThreadState {
40 projectId: ProjectId;45 projectId: ProjectId;
41 createdAt: string;46 createdAt: string;
42 runtimeMode: RuntimeMode;47 runtimeMode: RuntimeMode;
48 interactionMode: ProviderInteractionMode;
43 branch: string | null;49 branch: string | null;
44 worktreePath: string | null;50 worktreePath: string | null;
45 envMode: DraftThreadEnvMode;51 envMode: DraftThreadEnvMode;
...@@ -65,6 +71,7 @@ export interface DraftThreadState {...@@ -65,6 +71,7 @@ export interface DraftThreadState {
65 projectId: ProjectId;71 projectId: ProjectId;
66 createdAt: string;72 createdAt: string;
67 runtimeMode: RuntimeMode;73 runtimeMode: RuntimeMode;
74 interactionMode: ProviderInteractionMode;
68 branch: string | null;75 branch: string | null;
69 worktreePath: string | null;76 worktreePath: string | null;
70 envMode: DraftThreadEnvMode;77 envMode: DraftThreadEnvMode;
...@@ -89,6 +96,7 @@ interface ComposerDraftStoreState {...@@ -89,6 +96,7 @@ interface ComposerDraftStoreState {
89 createdAt?: string;96 createdAt?: string;
90 envMode?: DraftThreadEnvMode;97 envMode?: DraftThreadEnvMode;
91 runtimeMode?: RuntimeMode;98 runtimeMode?: RuntimeMode;
99 interactionMode?: ProviderInteractionMode;
92 },100 },
93 ) => void;101 ) => void;
94 setDraftThreadContext: (102 setDraftThreadContext: (
...@@ -100,6 +108,7 @@ interface ComposerDraftStoreState {...@@ -100,6 +108,7 @@ interface ComposerDraftStoreState {
100 createdAt?: string;108 createdAt?: string;
101 envMode?: DraftThreadEnvMode;109 envMode?: DraftThreadEnvMode;
102 runtimeMode?: RuntimeMode;110 runtimeMode?: RuntimeMode;
111 interactionMode?: ProviderInteractionMode;
103 },112 },
104 ) => void;113 ) => void;
105 clearProjectDraftThreadId: (projectId: ProjectId) => void;114 clearProjectDraftThreadId: (projectId: ProjectId) => void;
...@@ -270,6 +279,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -270,6 +279,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
270 candidateDraftThread.runtimeMode === "full-access"279 candidateDraftThread.runtimeMode === "full-access"
271 ? candidateDraftThread.runtimeMode280 ? candidateDraftThread.runtimeMode
272 : DEFAULT_RUNTIME_MODE,281 : DEFAULT_RUNTIME_MODE,
282 interactionMode:
283 candidateDraftThread.interactionMode === "plan" ||
284 candidateDraftThread.interactionMode === "default"
285 ? candidateDraftThread.interactionMode
286 : DEFAULT_INTERACTION_MODE,
273 branch: typeof branch === "string" ? branch : null,287 branch: typeof branch === "string" ? branch : null,
274 worktreePath: normalizedWorktreePath,288 worktreePath: normalizedWorktreePath,
275 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),289 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
...@@ -297,6 +311,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -297,6 +311,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
297 projectId: projectId as ProjectId,311 projectId: projectId as ProjectId,
298 createdAt: new Date().toISOString(),312 createdAt: new Date().toISOString(),
299 runtimeMode: DEFAULT_RUNTIME_MODE,313 runtimeMode: DEFAULT_RUNTIME_MODE,
314 interactionMode: DEFAULT_INTERACTION_MODE,
300 branch: null,315 branch: null,
301 worktreePath: null,316 worktreePath: null,
302 envMode: "local",317 envMode: "local",
...@@ -500,6 +515,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -500,6 +515,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
500 projectId,515 projectId,
501 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),516 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
502 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,517 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
518 interactionMode:
519 options?.interactionMode ??
520 existingThread?.interactionMode ??
521 DEFAULT_INTERACTION_MODE,
503 branch:522 branch:
504 options?.branch === undefined523 options?.branch === undefined
505 ? (existingThread?.branch ?? null)524 ? (existingThread?.branch ?? null)
...@@ -515,6 +534,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -515,6 +534,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
515 existingThread.projectId === nextDraftThread.projectId &&534 existingThread.projectId === nextDraftThread.projectId &&
516 existingThread.createdAt === nextDraftThread.createdAt &&535 existingThread.createdAt === nextDraftThread.createdAt &&
517 existingThread.runtimeMode === nextDraftThread.runtimeMode &&536 existingThread.runtimeMode === nextDraftThread.runtimeMode &&
537 existingThread.interactionMode === nextDraftThread.interactionMode &&
518 existingThread.branch === nextDraftThread.branch &&538 existingThread.branch === nextDraftThread.branch &&
519 existingThread.worktreePath === nextDraftThread.worktreePath &&539 existingThread.worktreePath === nextDraftThread.worktreePath &&
520 existingThread.envMode === nextDraftThread.envMode;540 existingThread.envMode === nextDraftThread.envMode;
...@@ -570,6 +590,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -570,6 +590,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
570 ? existing.createdAt590 ? existing.createdAt
571 : options.createdAt || existing.createdAt,591 : options.createdAt || existing.createdAt,
572 runtimeMode: options.runtimeMode ?? existing.runtimeMode,592 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
593 interactionMode: options.interactionMode ?? existing.interactionMode,
573 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),594 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
574 worktreePath: nextWorktreePath,595 worktreePath: nextWorktreePath,
575 envMode:596 envMode:
...@@ -580,6 +601,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -580,6 +601,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
580 nextDraftThread.projectId === existing.projectId &&601 nextDraftThread.projectId === existing.projectId &&
581 nextDraftThread.createdAt === existing.createdAt &&602 nextDraftThread.createdAt === existing.createdAt &&
582 nextDraftThread.runtimeMode === existing.runtimeMode &&603 nextDraftThread.runtimeMode === existing.runtimeMode &&
604 nextDraftThread.interactionMode === existing.interactionMode &&
583 nextDraftThread.branch === existing.branch &&605 nextDraftThread.branch === existing.branch &&
584 nextDraftThread.worktreePath === existing.worktreePath &&606 nextDraftThread.worktreePath === existing.worktreePath &&
585 nextDraftThread.envMode === existing.envMode;607 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...@@ -2,8 +2,10 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import {
5 deriveActivePlanState,
5 PROVIDER_OPTIONS,6 PROVIDER_OPTIONS,
6 derivePendingApprovals,7 derivePendingApprovals,
8 derivePendingUserInputs,
7 deriveWorkLogEntries,9 deriveWorkLogEntries,
8 hasToolActivityForTurn,10 hasToolActivityForTurn,
9 isLatestTurnSettled,11 isLatestTurnSettled,
...@@ -131,6 +133,130 @@ describe("derivePendingApprovals", () => {...@@ -131,6 +133,130 @@ describe("derivePendingApprovals", () => {
131 });133 });
132});134});
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
134describe("deriveWorkLogEntries", () => {260describe("deriveWorkLogEntries", () => {
135 it("omits tool started entries and keeps completed entries", () => {261 it("omits tool started entries and keeps completed entries", () => {
136 const activities: OrchestrationThreadActivity[] = [262 const activities: OrchestrationThreadActivity[] = [
apps/web/src/session-logic.ts+166
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 type OrchestrationLatestTurn,3 type OrchestrationLatestTurn,
4 type OrchestrationThreadActivity,4 type OrchestrationThreadActivity,
5 type ProviderKind,5 type ProviderKind,
6 type UserInputQuestion,
6 type TurnId,7 type TurnId,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
89
...@@ -33,6 +34,22 @@ export interface PendingApproval {...@@ -33,6 +34,22 @@ export interface PendingApproval {
33 detail?: string;34 detail?: string;
34}35}
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
36export type TimelineEntry =53export type TimelineEntry =
37 | {54 | {
38 id: string;55 id: string;
...@@ -161,6 +178,155 @@ export function derivePendingApprovals(...@@ -161,6 +178,155 @@ export function derivePendingApprovals(
161 );178 );
162}179}
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
164export function deriveWorkLogEntries(330export function deriveWorkLogEntries(
165 activities: ReadonlyArray<OrchestrationThreadActivity>,331 activities: ReadonlyArray<OrchestrationThreadActivity>,
166 latestTurnId: TurnId | undefined,332 latestTurnId: TurnId | undefined,
apps/web/src/store.test.ts+3-1
...@@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too...@@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { markThreadUnread, syncServerReadModel, type AppState } from "./store";4import { 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
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
8 return {8 return {
...@@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5-codex",13 model: "gpt-5-codex",
14 runtimeMode: DEFAULT_RUNTIME_MODE,14 runtimeMode: DEFAULT_RUNTIME_MODE,
15 interactionMode: DEFAULT_INTERACTION_MODE,
15 session: null,16 session: null,
16 messages: [],17 messages: [],
17 turnDiffSummaries: [],18 turnDiffSummaries: [],
...@@ -49,6 +50,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"...@@ -49,6 +50,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
49 title: "Thread",50 title: "Thread",
50 model: "gpt-5.3-codex",51 model: "gpt-5.3-codex",
51 runtimeMode: DEFAULT_RUNTIME_MODE,52 runtimeMode: DEFAULT_RUNTIME_MODE,
53 interactionMode: DEFAULT_INTERACTION_MODE,
52 branch: null,54 branch: null,
53 worktreePath: null,55 worktreePath: null,
54 latestTurn: null,56 latestTurn: null,
apps/web/src/store.ts+1
...@@ -257,6 +257,7 @@ export function syncServerReadModel(...@@ -257,6 +257,7 @@ export function syncServerReadModel(
257 thread.model,257 thread.model,
258 ),258 ),
259 runtimeMode: thread.runtimeMode,259 runtimeMode: thread.runtimeMode,
260 interactionMode: thread.interactionMode,
260 session: thread.session261 session: thread.session
261 ? {262 ? {
262 provider: toLegacyProvider(thread.session.providerName),263 provider: toLegacyProvider(thread.session.providerName),
apps/web/src/types.ts+4
...@@ -9,11 +9,14 @@ import type {...@@ -9,11 +9,14 @@ import type {
9 MessageId,9 MessageId,
10 CheckpointRef,10 CheckpointRef,
11 ProviderKind,11 ProviderKind,
12 ProviderInteractionMode,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
1314
14export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";15export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
15export type RuntimeMode = "approval-required" | "full-access";16export type RuntimeMode = "approval-required" | "full-access";
16export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";17export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
18export type InteractionMode = ProviderInteractionMode;
19export const DEFAULT_INTERACTION_MODE: InteractionMode = "default";
17export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;20export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
18export const DEFAULT_THREAD_TERMINAL_ID = "default";21export const DEFAULT_THREAD_TERMINAL_ID = "default";
19export const MAX_THREAD_TERMINAL_COUNT = 4;22export const MAX_THREAD_TERMINAL_COUNT = 4;
...@@ -78,6 +81,7 @@ export interface Thread {...@@ -78,6 +81,7 @@ export interface Thread {
78 title: string;81 title: string;
79 model: string;82 model: string;
80 runtimeMode: RuntimeMode;83 runtimeMode: RuntimeMode;
84 interactionMode: InteractionMode;
81 session: ThreadSession | null;85 session: ThreadSession | null;
82 messages: ChatMessage[];86 messages: ChatMessage[];
83 error: string | null;87 error: string | null;
apps/web/src/worktreeCleanup.test.ts+2-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { 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";
5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
...@@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -12,6 +12,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5.3-codex",13 model: "gpt-5.3-codex",
14 runtimeMode: DEFAULT_RUNTIME_MODE,14 runtimeMode: DEFAULT_RUNTIME_MODE,
15 interactionMode: DEFAULT_INTERACTION_MODE,
15 session: null,16 session: null,
16 messages: [],17 messages: [],
17 turnDiffSummaries: [],18 turnDiffSummaries: [],
apps/web/src/wsNativeApi.test.ts+19
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ProjectId,6 ProjectId,
7 ThreadId,7 ThreadId,
8 WS_CHANNELS,8 WS_CHANNELS,
9 WS_METHODS,
9 type ServerProviderStatus,10 type ServerProviderStatus,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
...@@ -330,6 +331,24 @@ describe("wsNativeApi", () => {...@@ -330,6 +331,24 @@ describe("wsNativeApi", () => {
330 });331 });
331 });332 });
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
333 it("forwards full-thread diff requests to the orchestration websocket method", async () => {352 it("forwards full-thread diff requests to the orchestration websocket method", async () => {
334 requestMock.mockResolvedValue({ diff: "patch" });353 requestMock.mockResolvedValue({ diff: "patch" });
335 const { createWsNativeApi } = await import("./wsNativeApi");354 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+1
...@@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi {...@@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi {
141 },141 },
142 projects: {142 projects: {
143 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),143 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
144 writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input),
144 },145 },
145 shell: {146 shell: {
146 openInEditor: (cwd, editor) =>147 openInEditor: (cwd, editor) =>
docs/plan-mode-deep-dive.md+369-458
...@@ -1,653 +1,564 @@...@@ -1,653 +1,564 @@
1# Plan Mode Implementation Plan1# Plan Mode Deep Dive
22
3## Goal3## 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 Server7The current app is:
8- Claude Agent SDK / Claude Code
9- Cursor ACP
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 state14This document updates the implementation plan to match that reality.
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
1715
18This document now serves as both:16---
1917
20- the architecture plan for shared orchestration and UI wiring18## Current Repo Baseline
21- the provider adapter implementation plan for each supported agent runtime
2219
23---20### 1. Shared contracts already have some plan primitives
2421
25## Source of truth22`packages/contracts/src/providerRuntime.ts` already includes:
2623
27### Codex24- `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`36### 2. Existing `runtimeMode` is not plan mode
34- `item/plan/delta`
35- `item/tool/requestUserInput`
36- `serverRequest/resolved`
37- lower-level `EventMsg` variants `plan_update`, `plan_delta`, and `request_user_input`
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 projection40- `approval-required`
42- they may preserve plan mode state and final `<proposed_plan>` output41- `full-access`
43- they should not be treated as proof that low-level plan events do or do not exist
4442
45### Claude43Plan 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 typings47- `interactionMode`
50- real local session JSONL evidence48- `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`53### 3. Server flow is already canonical-event-first
55- `ExitPlanMode` is a native `tool_use`
56- `AskUserQuestion.input.questions[]` is structured and adapter-parseable
5754
58### Cursor55The 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 docs64This matters because plan mode should fit into the same pipeline, not bypass it with a parallel websocket protocol.
63- current local ACP probe output
6465
65Confirmed from current docs + probe:66Important detail:
6667
67- ACP session modes include `agent`, `plan`, and `ask`68- `wsServer` broadcasts orchestration domain events, not raw provider runtime events
68- for product semantics, treat ACP mode as binary: `operatingMode: default | plan`69- if the web should see plan state live, that state must first become orchestration events and/or projections
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
7470
75---71### 4. The current read model has nowhere to put plan mode
7672
77## High-level product model73`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 mode82It 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`94### 5. The web app is snapshot-driven, not event-reduced
88- `plan`
8995
90This should be represented at runtime independently of plan content or prompt cards.96The client flow today is:
9197
92### 2. Structured plan state981. `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```ts109### 6. Current implementation status by provider
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```
109110
110### 3. Structured user-input prompt111#### 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```ts118Current gap:
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}
139119
140interface CanonicalUserInputAnswer {120- `item/tool/requestUserInput` is currently classified as `request.opened` with request type `tool_user_input`
141 promptId: string;121- it is not yet upgraded into `user-input.requested`
142 answers: Array<{122- there is no structured answer submission path yet
143 questionId: string;
144 selectedOptionIds?: string[];
145 text?: string;
146 }>;
147}
148```
149123
150This should remain distinct from approvals.124#### Claude Code
151125
152---126Currently implemented:
153127
154## Shared orchestration plan128- 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## Summary132Current 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 goals138#### Cursor
161139
162- normalize plan mode state from each provider140Currently implemented:
163- normalize structured prompts where possible141
164- preserve native payloads for later refinement142- `available_commands_update` maps to `session.configured`
165- avoid UI coupling to provider names143- thought/message chunks map to `content.delta`
166- support degraded conversational fallback where native structured prompts are unavailable144- 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
168---152---
169153
170## Orchestration work items154## Design Constraints
171155
172### 1. Extend canonical provider runtime contracts156### Keep the current transport model
173157
174Files:158Plan mode should continue to flow through:
175159
176- `packages/contracts/src/providerRuntime.ts`160- `ProviderRuntimeEvent`
177- `packages/contracts/src/provider.ts`161- orchestration commands/events
178- `EVENTS.md`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 payload168### Keep the client snapshot-driven
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
187169
188Required additions:170The client already assumes that server state is re-synced from snapshots. Plan mode should respect that.
189171
190```ts172That means:
191interface ProviderInteractiveCapabilities {
192 supportsPlanMode: boolean;
193 supportsStructuredPlanUpdates: boolean;
194 supportsPlanTextStreaming: boolean;
195 supportsStructuredUserInput: boolean;
196 supportsFreeformUserInput: boolean;
197 supportsExitPlanMode: boolean;
198}
199```
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-oriented178### Keep approvals separate from structured user input
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
207179
208### 2. Add canonical mode/capability publication from providers180The 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`186Plan-mode structured prompts are different and should not be forced into the approval model unless a provider genuinely models them as approval requests.
213- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
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`192- keep `raw` on internal canonical runtime events
220- current mode included in `session.configured`, `session.state.changed`, or a dedicated mode field on turn/session runtime metadata193- 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 orchestration196This 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`200- adapter/server internals may retain native payloads for correlation, debugging, and future parser improvements
227- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts`201- shared orchestration state and client-visible contracts should expose only canonical plan-mode data
228- any orchestration projection models feeding the web socket domain events
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 turn205---
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
236206
237### 4. Add frontend state model for plan mode207## Updated Shared Work Plan
238208
239Files likely involved:209## 1. Tighten shared contract naming before wiring features
240210
241- web app session/thread stores211The first step is not to invent a new architecture. It is to formalize the concepts that are currently missing from contracts.
242- conversation/event rendering layer
243- input composer / pending interaction surfaces
244212
245Add frontend state for:213### Add a plan operating mode concept
246214
247- current provider capabilities215Add a new shared contract for provider plan/default mode.
248- current operating mode
249- pending structured prompt
250- structured plan snapshot
251- streaming plan text buffer
252- final plan handoff state when present
253216
254### 5. Implement capability-driven UI rendering217Requirements:
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 UI223Preferred shape:
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
263224
264### 6. Implement front-to-back response submission path225```ts
226type ProviderInteractionMode = "default" | "plan";
227```
265228
266Requirements:229### Add typed provider interactive capabilities
267230
268- question card submit sends canonical answer payload to server231Current `session.configured.payload.config` is just `Record<string, unknown>`. That is too weak for capability-driven UI.
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
273232
274### 7. History and reconnect behavior233Add 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 active237- exported constant/object keyed by `ProviderKind`
279- resolved prompts should be rendered in history as completed interactions238- exported type derived from that object
280- plan snapshots should be replayable from persisted orchestration activity239- usable by adapters, orchestration code, and frontend rendering logic without duplicating provider capability tables
281- current streaming plan text should resume cleanly on reconnect if provider continues emitting deltas
282240
283---241Minimum fields:
284242
285## Provider adapter plans243```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 Server254Preferred usage:
288255
289### Summary256- 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 mapping262Current 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`269Preferred additions:
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`
306270
307### Adapter implementation271- 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`277## 2. Extend the orchestration read model
312- related tests in `apps/server/src/provider/Layers/*Codex*.test.ts`
313278
314Implementation requirements:279The current read model is the main missing piece.
315280
3161. Keep native structured plan updates as-is:281Plan mode needs first-class projection state so reconnect and refresh behave like the rest of the app.
317- `turn/plan/updated` -> `turn.plan.updated`
318282
3192. Keep native plan text streaming as-is:283### Add first-class thread plan state
320- `item/plan/delta` -> `content.delta` with `streamKind: "plan_text"`
321284
3223. Keep native structured user-input as-is:285Preferred thread-level additions:
323- `item/tool/requestUserInput` -> `user-input.requested`
324- also emit `request.opened` with a transport request type
325286
3264. On client answer submission:287- `interactionMode`
327- route answer to the corresponding Codex pending request id288- `currentPlan`
328- emit `user-input.resolved`289- `pendingUserInput`
329- observe/forward `serverRequest/resolved` -> `request.resolved`290- `resolvedUserInputs`
291- optional `finalPlanArtifact`
330292
3315. Publish capabilities:293The exact nesting can vary, but it should be snapshot-friendly and not require replaying arbitrary activity payloads in the client.
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
338294
339### Codex-specific tests295`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 state297Preferred split:
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
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 Code302### Keep activities as secondary audit trail
350303
351### Summary304`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 evidence308This 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:315## 3. Update runtime ingestion to populate that state
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
369316
370Claude also has native:317`ProviderRuntimeIngestion` already knows how to:
371318
372- `permissionMode: 'plan'`319- turn assistant text into messages
373- `system:init.permissionMode`320- turn approval events into activities
374- `system:status.permissionMode`321- turn `turn.plan.updated` into an activity
375322
376### Adapter implementation323It 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`327- `turn.plan.updated` updates projected plan snapshot
381- `apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts`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:335The web architecture already has the right places.
386- `permissionMode: 'plan'` -> canonical mode `plan`
387- all other values -> canonical mode `default`
388336
3892. Detect `AskUserQuestion` tool uses:337### Store and types
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
393338
3943. Canonical question mapping for Claude:339Update:
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`
401340
4024. Implement answer submission path:341- `apps/web/src/types.ts`
403- on UI answer submit, convert canonical answers back into whatever Claude expects for the `AskUserQuestion` tool result path342- `apps/web/src/store.ts`
404- emit `user-input.resolved`
405- emit `request.resolved`
406343
4075. Detect `ExitPlanMode` tool uses:344The store should stay thin and snapshot-oriented. It should sync whatever new read-model fields the server exposes.
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
411345
4126. Structured plan behavior:346### Derived UI state
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
418347
4197. Publish capabilities:348Update:
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`
426349
427### Claude-specific tests350- `apps/web/src/session-logic.ts`
428351
429- `system:init` and `system:status` publish plan mode metadata352This is the natural place for:
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
435353
436### Claude-specific open question to resolve during implementation354- `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 confirmed362- `apps/web/src/components/ChatView.tsx`
443363
444---364Current UI already has:
445365
446## Provider 3: Cursor ACP366- 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### Summary370That 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 evidence375The 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`379## Provider Implementation Plan
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`
469380
470Current caveats:381## Provider 1: Codex
471382
472- no standard ACP event equivalent to Codex `turn/plan/updated`383Codex is closest to the target shape and should be the first end-to-end implementation.
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
475384
476### Adapter implementation385### Current status
477386
478Files:387Already present:
479388
480- `apps/server/src/provider/Layers/CursorAdapter.ts`389- native structured plan update mapping
481- `apps/server/src/provider/Layers/CursorAdapter.test.ts`390- native plan-text stream mapping
482- probe scripts under `scripts/`
483391
484Implementation requirements:392Missing:
485393
4861. Publish native operating mode:394- native structured prompt mapping for `item/tool/requestUserInput`
487- map ACP `plan` mode -> canonical `plan`395- structured answer submission path
488- map ACP `agent` and `ask` modes -> canonical `default`396- removal of the current empty-answer auto-ack path in `codexAppServerManager`
489- preserve raw ACP mode in native metadata for debugging397- projection and UI support for the already-emitted plan events
490398
4912. Implement documented Cursor extension methods:399### Required work
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
495400
4963. Use graceful fallback behavior:4011. Upgrade `item/tool/requestUserInput` from request-only handling to canonical `user-input.requested`.
497- render plan mode as mode state + assistant text stream4022. Preserve `request.opened` as transport/debug metadata when useful.
498- if no `cursor/ask_question` event is emitted, fall back to normal conversational input4033. Add adapter-side answer submission for the corresponding pending request/tool context.
499- if no parseable `cursor/update_todos` event is emitted, fall back to text-only plan rendering4044. 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:407### Codex-specific note
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
504408
5055. Publish capabilities:409The runtime event schemas already match Codex reasonably well, so this provider should define the canonical end-to-end behavior first.
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`
512410
513### Cursor-specific tests411## Provider 2: Claude Code
514412
515- ACP mode metadata maps to `plan` vs `default` correctly413Claude likely needs tool-aware adaptation rather than pure transport mapping.
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
521414
522### Cursor-specific follow-up probe work415### Current status
523416
524Add dedicated probes for:417Already present:
525418
526- mode-switching during an active session419- generic session/runtime stream integration
527- real payload shapes for `cursor/ask_question`, `cursor/create_plan`, and `cursor/update_todos`420- approval bridging
528- any request types besides `session/request_permission`
529- whether extension methods are always emitted or only for specific prompt styles
530421
531---422Missing:
532423
533## Frontend implementation plan424- plan/default operating mode mapping
425- `AskUserQuestion` -> `user-input.requested`
426- `ExitPlanMode` -> final-plan or plan-state handoff
534427
535### Summary428### 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 support439## Provider 3: Cursor
540440
541- standard conversation441Cursor should start from the same canonical server pipeline, but with a more conservative adapter.
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
547442
548### Rendering rules443### Current status
549444
5501. Show operating mode indicator whenever current mode is `plan`445Already present:
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`
556446
557### Submission behavior447- session/update stream handling
448- reasoning/assistant text mapping
449- approval bridging
558450
559- option click / freeform answer submits canonical answer payload451Missing:
560- disable duplicate submits while request is pending resolution
561- preserve pending-card state across reconnects if request remains open
562452
563---453- plan/default operating mode mapping
454- structured prompt mapping
455- structured plan/todo mapping
564456
565## Data flow end to end457### Required work
566458
5671. Provider starts session and publishes capabilities + mode4591. Detect and publish Cursor operating mode separately from access-policy runtime mode.
5682. User starts plan-mode turn or provider enters plan mode4602. Map documented extension methods such as `cursor/ask_question` and `cursor/update_todos` only when payloads are confirmed.
5693. Adapter emits structured plan and/or plan text events when available4613. Fall back to plan-mode indicator plus normal assistant/reasoning text if structured Cursor plan events are absent.
5704. Adapter emits `user-input.requested` when provider asks a structured question4624. Preserve raw ACP payloads so the adapter can improve without changing higher layers.
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
577463
578---464---
579465
580## Test plan466## Frontend Rendering Plan
581467
582### Contracts468The frontend should remain capability-driven, but using real repo seams.
583469
584Files:470### Active state
585471
586- `packages/contracts/src/providerRuntime.test.ts`472Render from projected snapshot fields:
587- related schema tests
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 schema480### History state
592- structured user-input prompt schema
593- structured user-input answer schema
594- mode metadata schema
595481
596### Server/provider tests482Render 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`489### Placement
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`
604490
605Add coverage for:491Recommended initial placement:
606492
607- capabilities emission4931. Active pending prompt and active plan panel above the timeline, near the current approvals stack.
608- mode propagation4942. Resolved prompt/plan history in the timeline.
609- plan update propagation
610- plan text propagation
611- structured prompt lifecycle
612- answer resolution lifecycle
613- reconnect/history replay behavior
614495
615### Web/UI tests496This fits the existing `ChatView` layout with the least architectural churn.
497
498---
499
500## Test Plan
501
502### Contracts
616503
617Add coverage for:504Add coverage for:
618505
619- question card rendering from canonical prompt506- new interaction-mode schema
620- question answer submission507- new capability schema
621- resolved question history rendering508- new structured-answer command schema
622- structured plan step rendering509- any read-model additions for active plan/prompt state
623- text-only plan rendering
624- capability-based fallback rendering for Cursor
625510
626### Probe / fixture tests511Retain existing coverage for:
627512
628- preserve the Claude transcript-derived `AskUserQuestion` and `ExitPlanMode` payloads as fixtures513- `turn.plan.updated`
629- preserve Codex protocol fixtures for `turn/plan/updated`, `item/plan/delta`, and `item/tool/requestUserInput`514- `content.delta(plan_text)`
630- preserve ACP probe summaries as fixtures for unsupported-capability assertions515- `user-input.requested`
516- `user-input.resolved`
631517
632---518### Server
633519
634## Assumptions and defaults520Add or extend tests for:
635521
636- Codex is the reference provider for full structured plan/question UX.522- provider adapter plan/prompt mapping
637- Claude supports structured prompts via tool adaptation, but incremental structured plan updates are not assumed initially.523- `ProviderRuntimeIngestion` plan/prompt projection behavior
638- Cursor transport exposes `agent|plan|ask`, but product semantics collapse this to `operatingMode: default | plan`.524- `ProjectionPipeline` persistence and replay
639- Cursor structured prompts/plan steps are driven by ACP extension methods when present, with fallback when absent.525- `ProjectionSnapshotQuery` returning active plan state
640- Product/UI should degrade gracefully rather than invent unsupported provider behavior.526- provider answer submission path
641- Raw provider payloads should always be retained where feasible to support future adapter refinement.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
643---540---
644541
645## Recommended execution order542## 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 contracts564That is the shape the implementation should follow.
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
packages/contracts/src/ipc.ts+7-1
...@@ -14,7 +14,12 @@ import type {...@@ -14,7 +14,12 @@ import type {
14 GitStatusInput,14 GitStatusInput,
15 GitStatusResult,15 GitStatusResult,
16} from "./git";16} from "./git";
17import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project";17import type {
18 ProjectSearchEntriesInput,
19 ProjectSearchEntriesResult,
20 ProjectWriteFileInput,
21 ProjectWriteFileResult,
22} from "./project";
18import type { ServerConfig } from "./server";23import type { ServerConfig } from "./server";
19import type {24import type {
20 TerminalClearInput,25 TerminalClearInput,
...@@ -104,6 +109,7 @@ export interface NativeApi {...@@ -104,6 +109,7 @@ export interface NativeApi {
104 };109 };
105 projects: {110 projects: {
106 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;111 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
112 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
107 };113 };
108 shell: {114 shell: {
109 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;115 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
packages/contracts/src/orchestration.test.ts+6-1
...@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";...@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";2import { Effect, Schema } from "effect";
33
4import {4import {
5 DEFAULT_PROVIDER_INTERACTION_MODE,
5 DEFAULT_RUNTIME_MODE,6 DEFAULT_RUNTIME_MODE,
6 OrchestrationGetTurnDiffInput,7 OrchestrationGetTurnDiffInput,
7 OrchestrationSession,8 OrchestrationSession,
...@@ -110,6 +111,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()...@@ -110,6 +111,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
110 });111 });
111 assert.strictEqual(parsed.provider, undefined);112 assert.strictEqual(parsed.provider, undefined);
112 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);113 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
114 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
113 }),115 }),
114);116);
115117
...@@ -131,6 +133,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (...@@ -131,6 +133,7 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
131 });133 });
132 assert.strictEqual(parsed.provider, "claudeCode");134 assert.strictEqual(parsed.provider, "claudeCode");
133 assert.strictEqual(parsed.runtimeMode, "full-access");135 assert.strictEqual(parsed.runtimeMode, "full-access");
136 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
134 }),137 }),
135);138);
136139
...@@ -150,10 +153,11 @@ it.effect("accepts cursor provider in thread.turn.start", () =>...@@ -150,10 +153,11 @@ it.effect("accepts cursor provider in thread.turn.start", () =>
150 createdAt: "2026-01-01T00:00:00.000Z",153 createdAt: "2026-01-01T00:00:00.000Z",
151 });154 });
152 assert.strictEqual(parsed.provider, "cursor");155 assert.strictEqual(parsed.provider, "cursor");
156 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
153 }),157 }),
154);158);
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", () =>
157 Effect.gen(function* () {161 Effect.gen(function* () {
158 const parsed = yield* decodeThreadTurnStartRequestedPayload({162 const parsed = yield* decodeThreadTurnStartRequestedPayload({
159 threadId: "thread-1",163 threadId: "thread-1",
...@@ -162,6 +166,7 @@ it.effect("decodes thread.turn-start-requested defaults for provider and runtime...@@ -162,6 +166,7 @@ it.effect("decodes thread.turn-start-requested defaults for provider and runtime
162 });166 });
163 assert.strictEqual(parsed.provider, undefined);167 assert.strictEqual(parsed.provider, undefined);
164 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);168 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
169 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
165 }),170 }),
166);171);
167172
packages/contracts/src/orchestration.ts+79
...@@ -32,6 +32,9 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";...@@ -32,6 +32,9 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
32export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);32export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
33export type RuntimeMode = typeof RuntimeMode.Type;33export type RuntimeMode = typeof RuntimeMode.Type;
34export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";34export 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";
35export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]);38export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]);
36export type ProviderRequestKind = typeof ProviderRequestKind.Type;39export type ProviderRequestKind = typeof ProviderRequestKind.Type;
37export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);40export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
...@@ -43,6 +46,8 @@ export const ProviderApprovalDecision = Schema.Literals([...@@ -43,6 +46,8 @@ export const ProviderApprovalDecision = Schema.Literals([
43 "cancel",46 "cancel",
44]);47]);
45export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;48export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
49export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
50export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
4651
47export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;52export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
48export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;53export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
...@@ -219,6 +224,9 @@ export const OrchestrationThread = Schema.Struct({...@@ -219,6 +224,9 @@ export const OrchestrationThread = Schema.Struct({
219 title: TrimmedNonEmptyString,224 title: TrimmedNonEmptyString,
220 model: TrimmedNonEmptyString,225 model: TrimmedNonEmptyString,
221 runtimeMode: RuntimeMode,226 runtimeMode: RuntimeMode,
227 interactionMode: ProviderInteractionMode.pipe(
228 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
229 ),
222 branch: Schema.NullOr(TrimmedNonEmptyString),230 branch: Schema.NullOr(TrimmedNonEmptyString),
223 worktreePath: Schema.NullOr(TrimmedNonEmptyString),231 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
224 latestTurn: Schema.NullOr(OrchestrationLatestTurn),232 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
...@@ -274,6 +282,9 @@ export const ThreadCreateCommand = Schema.Struct({...@@ -274,6 +282,9 @@ export const ThreadCreateCommand = Schema.Struct({
274 title: TrimmedNonEmptyString,282 title: TrimmedNonEmptyString,
275 model: TrimmedNonEmptyString,283 model: TrimmedNonEmptyString,
276 runtimeMode: RuntimeMode,284 runtimeMode: RuntimeMode,
285 interactionMode: ProviderInteractionMode.pipe(
286 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
287 ),
277 branch: Schema.NullOr(TrimmedNonEmptyString),288 branch: Schema.NullOr(TrimmedNonEmptyString),
278 worktreePath: Schema.NullOr(TrimmedNonEmptyString),289 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
279 createdAt: IsoDateTime,290 createdAt: IsoDateTime,
...@@ -303,6 +314,14 @@ export const ThreadRuntimeModeSetCommand = Schema.Struct({...@@ -303,6 +314,14 @@ export const ThreadRuntimeModeSetCommand = Schema.Struct({
303 createdAt: IsoDateTime,314 createdAt: IsoDateTime,
304});315});
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
306export const ThreadTurnStartCommand = Schema.Struct({325export const ThreadTurnStartCommand = Schema.Struct({
307 type: Schema.Literal("thread.turn.start"),326 type: Schema.Literal("thread.turn.start"),
308 commandId: CommandId,327 commandId: CommandId,
...@@ -318,6 +337,9 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -318,6 +337,9 @@ export const ThreadTurnStartCommand = Schema.Struct({
318 effort: Schema.optional(TrimmedNonEmptyString),337 effort: Schema.optional(TrimmedNonEmptyString),
319 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),338 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
320 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),339 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
340 interactionMode: ProviderInteractionMode.pipe(
341 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
342 ),
321 createdAt: IsoDateTime,343 createdAt: IsoDateTime,
322});344});
323345
...@@ -336,6 +358,7 @@ export const ClientThreadTurnStartCommand = Schema.Struct({...@@ -336,6 +358,7 @@ export const ClientThreadTurnStartCommand = Schema.Struct({
336 effort: Schema.optional(TrimmedNonEmptyString),358 effort: Schema.optional(TrimmedNonEmptyString),
337 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),359 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
338 runtimeMode: RuntimeMode,360 runtimeMode: RuntimeMode,
361 interactionMode: ProviderInteractionMode,
339 createdAt: IsoDateTime,362 createdAt: IsoDateTime,
340});363});
341364
...@@ -356,6 +379,15 @@ export const ThreadApprovalRespondCommand = Schema.Struct({...@@ -356,6 +379,15 @@ export const ThreadApprovalRespondCommand = Schema.Struct({
356 createdAt: IsoDateTime,379 createdAt: IsoDateTime,
357});380});
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
359export const ThreadCheckpointRevertCommand = Schema.Struct({391export const ThreadCheckpointRevertCommand = Schema.Struct({
360 type: Schema.Literal("thread.checkpoint.revert"),392 type: Schema.Literal("thread.checkpoint.revert"),
361 commandId: CommandId,393 commandId: CommandId,
...@@ -379,9 +411,11 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -379,9 +411,11 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([
379 ThreadDeleteCommand,411 ThreadDeleteCommand,
380 ThreadMetaUpdateCommand,412 ThreadMetaUpdateCommand,
381 ThreadRuntimeModeSetCommand,413 ThreadRuntimeModeSetCommand,
414 ThreadInteractionModeSetCommand,
382 ThreadTurnStartCommand,415 ThreadTurnStartCommand,
383 ThreadTurnInterruptCommand,416 ThreadTurnInterruptCommand,
384 ThreadApprovalRespondCommand,417 ThreadApprovalRespondCommand,
418 ThreadUserInputRespondCommand,
385 ThreadCheckpointRevertCommand,419 ThreadCheckpointRevertCommand,
386 ThreadSessionStopCommand,420 ThreadSessionStopCommand,
387]);421]);
...@@ -396,9 +430,11 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -396,9 +430,11 @@ export const ClientOrchestrationCommand = Schema.Union([
396 ThreadDeleteCommand,430 ThreadDeleteCommand,
397 ThreadMetaUpdateCommand,431 ThreadMetaUpdateCommand,
398 ThreadRuntimeModeSetCommand,432 ThreadRuntimeModeSetCommand,
433 ThreadInteractionModeSetCommand,
399 ClientThreadTurnStartCommand,434 ClientThreadTurnStartCommand,
400 ThreadTurnInterruptCommand,435 ThreadTurnInterruptCommand,
401 ThreadApprovalRespondCommand,436 ThreadApprovalRespondCommand,
437 ThreadUserInputRespondCommand,
402 ThreadCheckpointRevertCommand,438 ThreadCheckpointRevertCommand,
403 ThreadSessionStopCommand,439 ThreadSessionStopCommand,
404]);440]);
...@@ -485,10 +521,12 @@ export const OrchestrationEventType = Schema.Literals([...@@ -485,10 +521,12 @@ export const OrchestrationEventType = Schema.Literals([
485 "thread.deleted",521 "thread.deleted",
486 "thread.meta-updated",522 "thread.meta-updated",
487 "thread.runtime-mode-set",523 "thread.runtime-mode-set",
524 "thread.interaction-mode-set",
488 "thread.message-sent",525 "thread.message-sent",
489 "thread.turn-start-requested",526 "thread.turn-start-requested",
490 "thread.turn-interrupt-requested",527 "thread.turn-interrupt-requested",
491 "thread.approval-response-requested",528 "thread.approval-response-requested",
529 "thread.user-input-response-requested",
492 "thread.checkpoint-revert-requested",530 "thread.checkpoint-revert-requested",
493 "thread.reverted",531 "thread.reverted",
494 "thread.session-stop-requested",532 "thread.session-stop-requested",
...@@ -532,6 +570,9 @@ export const ThreadCreatedPayload = Schema.Struct({...@@ -532,6 +570,9 @@ export const ThreadCreatedPayload = Schema.Struct({
532 title: TrimmedNonEmptyString,570 title: TrimmedNonEmptyString,
533 model: TrimmedNonEmptyString,571 model: TrimmedNonEmptyString,
534 runtimeMode: RuntimeMode,572 runtimeMode: RuntimeMode,
573 interactionMode: ProviderInteractionMode.pipe(
574 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
575 ),
535 branch: Schema.NullOr(TrimmedNonEmptyString),576 branch: Schema.NullOr(TrimmedNonEmptyString),
536 worktreePath: Schema.NullOr(TrimmedNonEmptyString),577 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
537 createdAt: IsoDateTime,578 createdAt: IsoDateTime,
...@@ -558,6 +599,14 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({...@@ -558,6 +599,14 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({
558 updatedAt: IsoDateTime,599 updatedAt: IsoDateTime,
559});600});
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
561export const ThreadMessageSentPayload = Schema.Struct({610export const ThreadMessageSentPayload = Schema.Struct({
562 threadId: ThreadId,611 threadId: ThreadId,
563 messageId: MessageId,612 messageId: MessageId,
...@@ -578,6 +627,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({...@@ -578,6 +627,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
578 effort: Schema.optional(TrimmedNonEmptyString),627 effort: Schema.optional(TrimmedNonEmptyString),
579 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),628 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
580 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),629 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
630 interactionMode: ProviderInteractionMode.pipe(
631 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
632 ),
581 createdAt: IsoDateTime,633 createdAt: IsoDateTime,
582});634});
583635
...@@ -594,6 +646,13 @@ export const ThreadApprovalResponseRequestedPayload = Schema.Struct({...@@ -594,6 +646,13 @@ export const ThreadApprovalResponseRequestedPayload = Schema.Struct({
594 createdAt: IsoDateTime,646 createdAt: IsoDateTime,
595});647});
596648
649export const ThreadUserInputResponseRequestedPayload = Schema.Struct({
650 threadId: ThreadId,
651 requestId: ApprovalRequestId,
652 answers: ProviderUserInputAnswers,
653 createdAt: IsoDateTime,
654});
655
597export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({656export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({
598 threadId: ThreadId,657 threadId: ThreadId,
599 turnCount: NonNegativeInt,658 turnCount: NonNegativeInt,
...@@ -702,6 +761,11 @@ export const OrchestrationEvent = Schema.Union([...@@ -702,6 +761,11 @@ export const OrchestrationEvent = Schema.Union([
702 type: Schema.Literal("thread.runtime-mode-set"),761 type: Schema.Literal("thread.runtime-mode-set"),
703 payload: ThreadRuntimeModeSetPayload,762 payload: ThreadRuntimeModeSetPayload,
704 }),763 }),
764 Schema.Struct({
765 ...EventBaseFields,
766 type: Schema.Literal("thread.interaction-mode-set"),
767 payload: ThreadInteractionModeSetPayload,
768 }),
705 Schema.Struct({769 Schema.Struct({
706 ...EventBaseFields,770 ...EventBaseFields,
707 type: Schema.Literal("thread.message-sent"),771 type: Schema.Literal("thread.message-sent"),
...@@ -722,6 +786,11 @@ export const OrchestrationEvent = Schema.Union([...@@ -722,6 +786,11 @@ export const OrchestrationEvent = Schema.Union([
722 type: Schema.Literal("thread.approval-response-requested"),786 type: Schema.Literal("thread.approval-response-requested"),
723 payload: ThreadApprovalResponseRequestedPayload,787 payload: ThreadApprovalResponseRequestedPayload,
724 }),788 }),
789 Schema.Struct({
790 ...EventBaseFields,
791 type: Schema.Literal("thread.user-input-response-requested"),
792 payload: ThreadUserInputResponseRequestedPayload,
793 }),
725 Schema.Struct({794 Schema.Struct({
726 ...EventBaseFields,795 ...EventBaseFields,
727 type: Schema.Literal("thread.checkpoint-revert-requested"),796 type: Schema.Literal("thread.checkpoint-revert-requested"),
...@@ -791,6 +860,11 @@ export const OrchestrationPersistedEvent = Schema.Union([...@@ -791,6 +860,11 @@ export const OrchestrationPersistedEvent = Schema.Union([
791 eventType: Schema.Literal("thread.runtime-mode-set"),860 eventType: Schema.Literal("thread.runtime-mode-set"),
792 payload: ThreadRuntimeModeSetPayload,861 payload: ThreadRuntimeModeSetPayload,
793 }),862 }),
863 Schema.Struct({
864 ...PersistedEventBaseFields,
865 eventType: Schema.Literal("thread.interaction-mode-set"),
866 payload: ThreadInteractionModeSetPayload,
867 }),
794 Schema.Struct({868 Schema.Struct({
795 ...PersistedEventBaseFields,869 ...PersistedEventBaseFields,
796 eventType: Schema.Literal("thread.message-sent"),870 eventType: Schema.Literal("thread.message-sent"),
...@@ -811,6 +885,11 @@ export const OrchestrationPersistedEvent = Schema.Union([...@@ -811,6 +885,11 @@ export const OrchestrationPersistedEvent = Schema.Union([
811 eventType: Schema.Literal("thread.approval-response-requested"),885 eventType: Schema.Literal("thread.approval-response-requested"),
812 payload: ThreadApprovalResponseRequestedPayload,886 payload: ThreadApprovalResponseRequestedPayload,
813 }),887 }),
888 Schema.Struct({
889 ...PersistedEventBaseFields,
890 eventType: Schema.Literal("thread.user-input-response-requested"),
891 payload: ThreadUserInputResponseRequestedPayload,
892 }),
814 Schema.Struct({893 Schema.Struct({
815 ...PersistedEventBaseFields,894 ...PersistedEventBaseFields,
816 eventType: Schema.Literal("thread.checkpoint-revert-requested"),895 eventType: Schema.Literal("thread.checkpoint-revert-requested"),
packages/contracts/src/project.ts+15
...@@ -2,6 +2,7 @@ import { Schema } from "effect";...@@ -2,6 +2,7 @@ import { Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
33
4export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;4export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
5export const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
56
6export const ProjectSearchEntriesInput = Schema.Struct({7export const ProjectSearchEntriesInput = Schema.Struct({
7 cwd: TrimmedNonEmptyString,8 cwd: TrimmedNonEmptyString,
...@@ -24,3 +25,17 @@ export const ProjectSearchEntriesResult = Schema.Struct({...@@ -24,3 +25,17 @@ export const ProjectSearchEntriesResult = Schema.Struct({
24 truncated: Schema.Boolean,25 truncated: Schema.Boolean,
25});26});
26export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;27export 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 {...@@ -14,8 +14,10 @@ import {
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
16 ProviderApprovalDecision,16 ProviderApprovalDecision,
17 ProviderInteractionMode,
17 ProviderKind,18 ProviderKind,
18 ProviderRequestKind,19 ProviderRequestKind,
20 ProviderUserInputAnswers,
19 RuntimeMode,21 RuntimeMode,
20 TurnCountRange,22 TurnCountRange,
21} from "./orchestration";23} from "./orchestration";
...@@ -92,6 +94,7 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -92,6 +94,7 @@ export const ProviderSendTurnInput = Schema.Struct({
92 ),94 ),
93 model: Schema.optional(TrimmedNonEmptyStringSchema),95 model: Schema.optional(TrimmedNonEmptyStringSchema),
94 effort: Schema.optional(TrimmedNonEmptyStringSchema),96 effort: Schema.optional(TrimmedNonEmptyStringSchema),
97 interactionMode: Schema.optional(ProviderInteractionMode),
95});98});
96export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;99export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
97100
...@@ -169,6 +172,13 @@ export const ProviderRespondToRequestInput = Schema.Struct({...@@ -169,6 +172,13 @@ export const ProviderRespondToRequestInput = Schema.Struct({
169});172});
170export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type;173export 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
172export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);182export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
173export type ProviderEventKind = typeof ProviderEventKind.Type;183export type ProviderEventKind = typeof ProviderEventKind.Type;
174184
packages/contracts/src/ws.ts+3-1
...@@ -29,7 +29,7 @@ import {...@@ -29,7 +29,7 @@ import {
29 TerminalWriteInput,29 TerminalWriteInput,
30} from "./terminal";30} from "./terminal";
31import { KeybindingRule } from "./keybindings";31import { KeybindingRule } from "./keybindings";
32import { ProjectSearchEntriesInput } from "./project";32import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
33import { OpenInEditorInput } from "./editor";33import { OpenInEditorInput } from "./editor";
3434
35// ── WebSocket RPC Method Names ───────────────────────────────────────35// ── WebSocket RPC Method Names ───────────────────────────────────────
...@@ -40,6 +40,7 @@ export const WS_METHODS = {...@@ -40,6 +40,7 @@ export const WS_METHODS = {
40 projectsAdd: "projects.add",40 projectsAdd: "projects.add",
41 projectsRemove: "projects.remove",41 projectsRemove: "projects.remove",
42 projectsSearchEntries: "projects.searchEntries",42 projectsSearchEntries: "projects.searchEntries",
43 projectsWriteFile: "projects.writeFile",
4344
44 // Shell methods45 // Shell methods
45 shellOpenInEditor: "shell.openInEditor",46 shellOpenInEditor: "shell.openInEditor",
...@@ -101,6 +102,7 @@ export const WebSocketRequestBody = Schema.Union([...@@ -101,6 +102,7 @@ export const WebSocketRequestBody = Schema.Union([
101102
102 // Project Search103 // Project Search
103 tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput),104 tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput),
105 tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput),
104106
105 // Shell methods107 // Shell methods
106 tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),108 tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),