From 99e3b721c5255ded20b00ba1798f848bfc0f1f65 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 00:43:47 -0700 Subject: [PATCH 01/18] fix(connect): diagnose incomplete headless server setup (#9602) --- apps/server/src/cli/connect.ts | 15 +- apps/server/src/cli/service.test.ts | 48 ++++++- apps/server/src/cli/service.ts | 12 +- apps/server/src/cloud/bootService.test.ts | 144 +++++++++++++++++-- apps/server/src/cloud/bootService.ts | 124 +++++++++++++++-- apps/server/src/cloud/http.ts | 12 +- apps/server/src/cloud/relayResponse.test.ts | 145 ++++++++++++++++++++ apps/server/src/cloud/relayResponse.ts | 82 +++++++++++ apps/server/src/server.ts | 11 +- docs/user/background-service.md | 36 ++++- docs/user/remote-access.md | 27 ++++ 11 files changed, 614 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/cloud/relayResponse.test.ts create mode 100644 apps/server/src/cloud/relayResponse.ts diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3f8e1d123da5..25cfb18f3402 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -35,6 +35,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BootService from "../cloud/bootService.ts"; import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; +import { filterRelayResponse } from "../cloud/relayResponse.ts"; import { CLOUD_LINKED_USER_ID, isAgentActivityPublishingEnabledValue, @@ -208,6 +209,8 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b ` Relay: ${status.relayUrl ?? "not provisioned"}`, ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, ...formatRelayClientStatus(status.relayClient), + "", + "This is saved setup, not a live connection check. Check the background service with `t3 service status`.", ...(nextStep ? ["", `Next: ${nextStep}`] : []), ].join("\n"); } @@ -346,7 +349,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f ).pipe( HttpClientRequest.bearerToken(token.value.accessToken), httpClient.execute, - Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(filterRelayResponse), Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), withRelayClientTracing, ); @@ -689,17 +692,17 @@ export const connectCommand = Command.make("connect", { // Show which account was linked so an unexpected identity (an // authorization code for a different account) is visible before the // machine is brought online. - yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`); + yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`); - // Connect itself already succeeded; a boot-service failure must not - // fail the command, just tell the user what happened and move on. + // Authorization is stored. If service setup fails, preserve it and + // show how to run the server manually. const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); if (background) { const platform = yield* HostProcessPlatform; yield* Console.log( platform === "darwin" - ? "\n✓ Background service ready\n\nT3 Code will stay reachable while you are logged in to this Mac." - : "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.", + ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." + : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", ); return; } diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index b442d46df368..38732e42987a 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -45,10 +45,38 @@ it("reports the installed service version and host paths", () => { it("gives a direct repair command for a stale service", () => { assert.include( formatServiceStatus({ ...status, current: false }, "0.0.29"), - "Next: Run `npx t3@latest service update`.", + "Next: Run `npx t3@0.0.29 service update`.", ); }); +it("explains an incomplete nightly installation and keeps repair on its installed version", () => { + const output = formatServiceStatus( + { + ...status, + current: false, + installedVersion: "0.0.32-nightly.1", + problems: ["linger-disabled", "service-stopped"], + }, + "0.0.32-nightly.1", + ); + + expect(output).toContain("[linger-disabled]"); + expect(output).toContain("last login session ends"); + expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); + expect(output).toContain("[service-stopped]"); + expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); + expect(output).not.toContain("t3@latest"); +}); + +it("suggests the newer CLI version when the installed service needs an update", () => { + const output = formatServiceStatus( + { ...status, current: false, installedVersion: "0.0.28" }, + "0.0.29", + ); + expect(output).toContain("npx t3@0.0.29 service update"); + expect(output).not.toContain("npx t3@0.0.28 service update"); +}); + it("explains where the service is supported", () => { assert.include( formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), @@ -151,6 +179,15 @@ it.effect.each([ name: "the same version", state: { ...status, current: false, installedVersion: packageJson.version }, }, + { + name: "an incomplete install of the same version", + state: { + ...status, + current: false, + installedVersion: packageJson.version, + problems: ["linger-disabled"] as const, + }, + }, { name: "an unknown version", state: { ...status, current: false } }, ])("installs or repairs $name without an override", ({ state }) => Effect.gen(function* () { @@ -201,3 +238,12 @@ it.effect("keeps onboarding successful when a newer version appears before insta expect(ready).toBe(false); }), ); + +it.effect("keeps the manual-server fallback when background prerequisites fail", () => + Effect.gen(function* () { + const ready = yield* recoverServiceOnboardingOffer( + Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })), + ); + expect(ready).toBe(false); + }), +); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 3e2e893334f0..0cea18ff4977 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -68,6 +68,9 @@ export function formatServiceStatus( return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; } const installedVersion = status.installedVersion ?? cliVersion; + const problems = (status.problems ?? []).map( + (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`, + ); if ( !status.current && status.installedVersion !== undefined && @@ -78,6 +81,7 @@ export function formatServiceStatus( ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...problems, ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, ].join("\n"); } @@ -86,7 +90,8 @@ export function formatServiceStatus( ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, - ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), + ...problems, + ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), ].join("\n"); } @@ -189,6 +194,9 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { yield* Console.log("T3 Code is already set up to run in the background on this machine."); return true; } + for (const problem of status.problems ?? []) { + yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); + } if ( installed && status.installedVersion !== undefined && @@ -239,6 +247,8 @@ export const recoverServiceOnboardingOffer = ( Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceInstallError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServicePrerequisiteError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceUpdatePendingError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceDowngradeRefusedError: (error) => diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 3e31ab3f6371..c820f93cd212 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -127,8 +127,17 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const commands: string[] = []; const timeouts = new Map(); - const control: { failCommand: string | undefined; stateAfterStop?: string } = { + const control: { + failCommand: string | undefined; + stateAfterStop?: string; + linger: string; + enabled: boolean; + active: boolean; + } = { failCommand: undefined, + linger: "yes", + enabled: true, + active: true, }; const runner = ProcessRunner.ProcessRunner.of({ run: Effect.fn("test.run_boot_service_command")(function* ( @@ -137,6 +146,11 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const command = `${input.command} ${input.args.join(" ")}`; commands.push(command); timeouts.set(command, input.timeout); + const failed = command === control.failCommand; + if (!failed && command === "loginctl enable-linger --no-ask-password 501") + control.linger = "yes"; + if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; + if (!failed && command === "systemctl --user restart t3code.service") control.active = true; if ( control.stateAfterStop !== undefined && (command === "systemctl --user stop t3code.service" || @@ -145,9 +159,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); } return { - stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", + stdout: + input.args[1] === "--version" + ? "t3 v1.2.3\n" + : input.command === "loginctl" && input.args[0] === "show-user" + ? `${control.linger}\n` + : input.args[1] === "is-enabled" + ? control.enabled + ? "enabled\n" + : "disabled\n" + : "", stderr: "", - code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), + code: ChildProcessSpawner.ExitCode( + failed || (input.args[1] === "is-active" && !control.active) ? 1 : 0, + ), timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -182,10 +207,103 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ), ); const service = yield* makeService(); - return { service, makeService, fs, statePath, commands, timeouts, control }; + return { service, makeService, fs, statePath, commands, timeouts, control, runtime }; }); it.layer(NodeServices.layer)("boot service install", (it) => { + it.effect( + "fails before installing files or validating a runtime when lingering needs an administrator", + () => + Effect.gen(function* () { + const { service, fs, statePath, commands, control, runtime } = yield* makeHarness(); + const before = yield* service.status; + control.linger = "no"; + control.failCommand = "loginctl enable-linger --no-ask-password 501"; + yield* fs.remove(runtime.sentinelPath); + + const error = yield* service.install().pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "BootServicePrerequisiteError", + problem: "linger-disabled", + }); + expect(error.message).toContain('sudo loginctl enable-linger "$(id -un)"'); + expect(error.message).toContain("last login session ends"); + expect(yield* fs.exists(before.unitPath)).toBe(false); + expect(yield* fs.exists(statePath)).toBe(false); + expect( + commands.some((command) => command.startsWith("npm ") || command.includes("--version")), + ).toBe(false); + expect( + commands.some( + (command) => command.includes("daemon-reload") || command.includes("restart"), + ), + ).toBe(false); + expect(yield* fs.readFileString(before.logPath)).toContain("[linger-disabled]"); + }), + ); + + it.effect( + "detects a partial install and preserves the running service when repair lacks permission", + () => + Effect.gen(function* () { + const { service, fs, statePath, commands, control } = yield* makeHarness(); + const plan = yield* service.install(); + const before = yield* fs.readFileString(statePath); + const unit = yield* fs.readFileString(plan.unitPath); + control.linger = "no"; + control.failCommand = "loginctl enable-linger --no-ask-password 501"; + + expect(yield* service.status).toMatchObject({ + current: false, + problems: ["linger-disabled"], + }); + commands.length = 0; + expect((yield* service.install().pipe(Effect.flip))._tag).toBe( + "BootServicePrerequisiteError", + ); + expect(yield* fs.readFileString(statePath)).toBe(before); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect(commands).not.toContain("systemctl --user stop t3code.service"); + }), + ); + + it.effect("enables lingering before installing and repairs stopped or disabled services", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness(); + control.linger = "no"; + yield* service.install(); + expect(control.linger).toBe("yes"); + expect(commands.indexOf("loginctl enable-linger --no-ask-password 501")).toBeLessThan( + commands.indexOf("systemctl --user daemon-reload"), + ); + + control.enabled = false; + control.active = false; + expect(yield* service.status).toMatchObject({ + current: false, + problems: ["service-disabled", "service-stopped"], + }); + yield* service.install(); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect.each([ + { command: "systemctl --user show-environment", problem: "user-manager-unavailable" }, + { command: "loginctl show-user 501 --property=Linger --value", problem: "linger-unavailable" }, + ])("reports failed prerequisite probes without installing: $command", ({ command, problem }) => + Effect.gen(function* () { + const { service, fs, statePath, control } = yield* makeHarness(); + control.failCommand = command; + expect(yield* service.install().pipe(Effect.flip)).toMatchObject({ + _tag: "BootServicePrerequisiteError", + problem, + }); + expect(yield* fs.exists(statePath)).toBe(false); + }), + ); + it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); @@ -286,8 +404,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); expect( - commands.filter((command) => - command.startsWith(platform === "linux" ? "systemctl " : "launchctl "), + commands.filter( + (command) => + command.startsWith(platform === "linux" ? "systemctl " : "launchctl ") && + !command.includes("show-environment"), ), ).toEqual( platform === "linux" @@ -351,7 +471,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); - expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ "systemctl --user stop t3code.service", "systemctl --user daemon-reload", "systemctl --user restart t3code.service", @@ -382,7 +506,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { "BootServiceUpdatePendingError", ); expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); - expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ "systemctl --user stop t3code.service", "systemctl --user restart t3code.service", ]); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 59530498076e..50ffcedf74b8 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -235,7 +235,6 @@ function systemdManager(input: { command: "systemctl", args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], }, - { step: "enabling lingering for this user", command: "loginctl", args: ["enable-linger"] }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", @@ -409,6 +408,40 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass()( + "BootServicePrerequisiteError", + { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`; + } +} + export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass()( "BootServiceUpdatePendingError", {}, @@ -434,6 +467,7 @@ export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError | BootServiceInstallError + | BootServicePrerequisiteError | BootServiceUpdatePendingError | BootServiceDowngradeRefusedError; @@ -442,6 +476,7 @@ export interface BootServiceStatus { readonly installed: boolean; readonly current: boolean; readonly installedVersion?: string; + readonly problems?: ReadonlyArray; readonly unitPath: string; readonly logPath: string; } @@ -539,6 +574,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : Effect.succeed(detectedManager), ); + const logFailure = (error: { readonly message: string }) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { flag: "a" }), + ), + Effect.ignore, + ); + const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( step: string, command: string, @@ -557,16 +600,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { stderrLength: result.stderr.length, }), ), - Effect.tapError((error) => - DateTime.now.pipe( - Effect.flatMap((now) => - fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { - flag: "a", - }), - ), - Effect.ignore, - ), - ), + Effect.tapError(logFailure), ); }); @@ -587,6 +621,66 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { { discard: true }, ); + const probe = (command: string, args: ReadonlyArray) => + runner.run({ command, args, timeout: Duration.seconds(5) }).pipe(Effect.option); + const succeeded = (result: Option.Option) => + Option.isSome(result) && result.value.code === 0; + const lingerArgs = [ + "show-user", + ...(uid === undefined ? [] : [String(uid)]), + "--property=Linger", + "--value", + ]; + const readSystemdProblems = Effect.fn("cloud.boot_service.read_systemd_problems")(function* ( + includeService: boolean, + ) { + const [manager, linger] = yield* Effect.all( + [probe("systemctl", ["--user", "show-environment"]), probe("loginctl", lingerArgs)], + { concurrency: "unbounded" }, + ); + const problems: BootServiceProblem[] = []; + if (!succeeded(manager)) problems.push("user-manager-unavailable"); + const lingering = succeeded(linger) && Option.isSome(linger) ? linger.value.stdout.trim() : ""; + if (lingering !== "yes") { + problems.push(lingering === "no" ? "linger-disabled" : "linger-unavailable"); + } + if (includeService && succeeded(manager)) { + const [enabled, active] = yield* Effect.all( + [ + probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]), + probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]), + ], + { concurrency: "unbounded" }, + ); + if ( + !succeeded(enabled) || + (Option.isSome(enabled) && enabled.value.stdout.trim() !== "enabled") + ) { + problems.push("service-disabled"); + } + if (!succeeded(active)) problems.push("service-stopped"); + } + return problems; + }); + + const requireSystemdPrerequisites = Effect.gen(function* () { + const problems = yield* readSystemdProblems(false); + const unavailable = problems.find((problem) => problem !== "linger-disabled"); + if (unavailable) return yield* new BootServicePrerequisiteError({ problem: unavailable }); + if (!problems.includes("linger-disabled")) return; + yield* runStep("enabling lingering for this user", "loginctl", [ + "enable-linger", + "--no-ask-password", + ...(uid === undefined ? [] : [String(uid)]), + ]).pipe( + Effect.mapError( + (cause) => new BootServicePrerequisiteError({ problem: "linger-disabled", cause }), + ), + ); + const remaining = yield* readSystemdProblems(false); + if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] }); + }); + const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; }) { @@ -595,6 +689,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { .makeDirectory(input.logsDir, { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // A permissions failure must not leave a partial install or stop a working server. + if (manager.kind === "systemd") { + yield* requireSystemdPrerequisites.pipe(Effect.tapError(logFailure)); + } + // Prepare every immutable artifact before stopping the installed unit. yield* ensurePinnedRuntimeInstalled({ baseDir: input.baseDir, @@ -743,11 +842,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") : contents; + const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : []; return { supported: true, installed: true, ...(installedVersion === undefined ? {} : { installedVersion }), + problems, current: + problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && runtimeEntryExists && diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 29fdfe8ece2f..e0d458b4b97c 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -85,6 +85,7 @@ import { import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; +import { filterRelayResponse, relayRequestError } from "./relayResponse.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; @@ -526,14 +527,9 @@ const relayClientRequest = ( HttpClientRequest.bearerToken(input.token), HttpClientRequest.bodyJson(input.payload), Effect.flatMap(dependencies.httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(filterRelayResponse), Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), - Effect.mapError( - (cause) => - new EnvironmentHttpInternalServerError({ - message: `T3 Connect relay request failed: ${String(cause)}`, - }), - ), + Effect.mapError(relayRequestError), withRelayClientTracing, ); @@ -720,7 +716,7 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( ).pipe( HttpClientRequest.bearerToken(token.value.accessToken), dependencies.httpClient.execute, - Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(filterRelayResponse), Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), withRelayClientTracing, ); diff --git a/apps/server/src/cloud/relayResponse.test.ts b/apps/server/src/cloud/relayResponse.test.ts new file mode 100644 index 000000000000..9f53378f14ce --- /dev/null +++ b/apps/server/src/cloud/relayResponse.test.ts @@ -0,0 +1,145 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; + +const response = ( + status: number, + body: string | Record, + headers?: Record, +) => + HttpClientResponse.fromWeb( + HttpClientRequest.post("https://relay.example.test/v1/client/environment-links"), + typeof body === "string" + ? new Response(body, { status, ...(headers ? { headers } : {}) }) + : Response.json(body, { status, ...(headers ? { headers } : {}) }), + ); + +it("reports a transport failure category without exposing request or cause details", () => { + const error = relayRequestError( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.post("https://relay.example.test/link?token=private-token"), + description: "private transport details", + cause: new Error("private cause details"), + }), + }), + ); + + expect(error._tag).toBe("EnvironmentHttpInternalServerError"); + expect(error.message).toContain("TransportError"); + expect(error.message).toContain("network connection"); + expect(error.message).not.toContain("relay.example.test"); + expect(error.message).not.toContain("private"); + expect(shouldRetryCloudLink(error)).toBe(true); +}); + +it.effect("reports the tunnel limit and relay trace instead of a generic 403", () => + Effect.gen(function* () { + const error = yield* filterRelayResponse( + response(403, { + _tag: "RelayEnvironmentLinkLimitExceededError", + code: "environment_link_limit_exceeded", + maxTunnels: 3, + traceId: "trace-limit", + }), + ).pipe(Effect.mapError(relayRequestError), Effect.flip); + + expect(error._tag).toBe("EnvironmentHttpForbiddenError"); + expect(error.message).toContain("at most 3 tunnels"); + expect(error.message).toContain("Unlink an unused environment"); + expect(error.message).toContain("Trace ID: trace-limit"); + }), +); + +it.effect("makes revoked authorization actionable and non-retryable", () => + Effect.gen(function* () { + const error = yield* filterRelayResponse( + response(401, { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-auth", + }), + ).pipe(Effect.mapError(relayRequestError), Effect.flip); + + expect(error._tag).toBe("EnvironmentHttpUnauthorizedError"); + expect(error.message).toContain("invalid_bearer"); + expect(error.message).toContain("t3 connect login"); + expect(error.message).toContain("Trace ID: trace-auth"); + }), +); + +it.effect("reports an unrecognized access denial without printing its response body", () => + Effect.gen(function* () { + const error = yield* filterRelayResponse( + response(403, "private upstream details", { + "content-type": "text/html", + "cf-ray": "abcdef1234-IAD", + }), + ).pipe(Effect.flip); + + expect(error._tag).toBe("EnvironmentHttpForbiddenError"); + expect(error.message).toContain("HTTP 403"); + expect(error.message).toContain("proxy or firewall"); + expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD"); + expect(error.message).not.toContain("private upstream details"); + }), +); + +it.effect.each([408, 429, 500, 502, 503, 504])( + "keeps transient HTTP %s failures retryable", + (status) => + Effect.gen(function* () { + const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip); + expect(error._tag).toBe("EnvironmentHttpInternalServerError"); + expect(error.message).toContain(`HTTP ${status}`); + }), +); + +it.effect.each([ + { status: 401, attempts: 1 }, + { status: 403, attempts: 1 }, + { status: 429, attempts: 2 }, + { status: 503, attempts: 2 }, +])("stops rejected startup links but retries temporary failures: $status", ({ status, attempts }) => + Effect.gen(function* () { + let requests = 0; + const result = yield* Effect.suspend(() => { + requests++; + return filterRelayResponse(response(requests === 1 ? status : 200, "{}")); + }).pipe( + Effect.mapError(relayRequestError), + Effect.retry({ while: shouldRetryCloudLink, times: 1 }), + Effect.result, + ); + expect(requests).toBe(attempts); + expect(result._tag).toBe(attempts === 1 ? "Failure" : "Success"); + }), +); + +it.effect("keeps the relay failure reason and trace when tunnel cleanup fails", () => + Effect.gen(function* () { + const error = yield* filterRelayResponse( + response(500, { + _tag: "RelayInternalError", + code: "internal_error", + reason: "upstream_unavailable", + traceId: "trace-cleanup", + }), + ).pipe(Effect.flip); + + expect(error._tag).toBe("EnvironmentHttpInternalServerError"); + expect(error.message).toContain("upstream_unavailable"); + expect(error.message).toContain("Trace ID: trace-cleanup"); + }), +); + +it.effect("leaves successful response bodies available to their decoder", () => + Effect.gen(function* () { + const result = yield* filterRelayResponse(response(200, '{"ok":true}')); + expect(yield* result.json).toEqual({ ok: true }); + }), +); diff --git a/apps/server/src/cloud/relayResponse.ts b/apps/server/src/cloud/relayResponse.ts new file mode 100644 index 000000000000..df04642158eb --- /dev/null +++ b/apps/server/src/cloud/relayResponse.ts @@ -0,0 +1,82 @@ +import { + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, +} from "@t3tools/contracts"; +import { RelayProtectedError } from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { isHttpClientError } from "effect/unstable/http/HttpClientError"; + +const isRelayResponseError = Schema.is( + Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, + ]), +); + +export function relayRequestError(cause: unknown) { + return isRelayResponseError(cause) + ? cause + : new EnvironmentHttpInternalServerError({ + message: `Could not complete the T3 Connect relay request. ${isHttpClientError(cause) ? `The relay request failed (${cause.reason._tag}).` : "The relay returned an unexpected response."} Check this machine's network connection and relay availability, then retry.`, + }); +} + +const isPermanentCloudLinkError = Schema.is( + Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpForbiddenError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpConflictError, + ]), +); + +export const shouldRetryCloudLink = (error: unknown): boolean => !isPermanentCloudLinkError(error); + +function recoveryHint(error: RelayProtectedError): string { + switch (error._tag) { + case "RelayEnvironmentLinkLimitExceededError": + return "Unlink an unused environment in T3 Connect, then restart T3 Code on this machine."; + case "RelayAuthInvalidError": + return "Run `t3 connect login` to check this machine's authorization. If the stored credential was revoked, sign out with `t3 connect logout`, then run `t3 connect` again. Restart T3 Code after signing in."; + case "RelayEnvironmentLinkProofExpiredError": + case "RelayEnvironmentLinkProofInvalidError": + return "Check this machine's date and time, update T3 Code, then restart it."; + default: + return "Retry when the relay is available. If this continues, include the trace ID when reporting it."; + } +} + +/** Preserve relay diagnostics before converting permanent rejections into non-retryable errors. */ +export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(function* ( + response: HttpClientResponse.HttpClientResponse, +) { + if (response.status >= 200 && response.status < 300) return response; + const decoded = yield* HttpClientResponse.schemaBodyJson(RelayProtectedError)(response).pipe( + Effect.option, + ); + const ray = response.headers["cf-ray"]; + const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : ""; + const message = Option.isSome(decoded) + ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.` + : `T3 Connect relay returned HTTP ${response.status} without a recognized error response. Check relay access and any proxy or firewall restrictions, then restart T3 Code.${requestId}`; + + if (response.status === 401) return yield* new EnvironmentHttpUnauthorizedError({ message }); + if (response.status === 403) return yield* new EnvironmentHttpForbiddenError({ message }); + if ( + response.status >= 400 && + response.status < 500 && + response.status !== 408 && + response.status !== 429 + ) { + return yield* new EnvironmentHttpBadRequestError({ message }); + } + return yield* new EnvironmentHttpInternalServerError({ message }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0e02bf464fa7..c0be0c444573 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,4 +1,5 @@ import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -106,6 +107,7 @@ import { releaseManagedTunnelOnShutdown, } from "./cloud/http.ts"; import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; +import { shouldRetryCloudLink } from "./cloud/relayResponse.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; @@ -675,7 +677,7 @@ export const makeServerLayer = Layer.unwrap( Effect.catchCause((cause) => Effect.logWarning( "Failed to release the managed tunnel on shutdown; the next link reuses it", - { cause }, + { errors: Cause.prettyErrors(cause).map((error) => error.message) }, ), ), Effect.asVoid, @@ -707,10 +709,7 @@ export const makeServerLayer = Layer.unwrap( // reachability after a restart. yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( Effect.retry({ - while: (error) => - error._tag !== "EnvironmentHttpBadRequestError" && - error._tag !== "EnvironmentHttpUnauthorizedError" && - error._tag !== "EnvironmentHttpConflictError", + while: shouldRetryCloudLink, schedule: Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -721,7 +720,7 @@ export const makeServerLayer = Layer.unwrap( Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), Effect.catch((cause) => Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { - cause, + message: cause.message, }), ), ); diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 23ac07cee0db..a89fc2f7842d 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -11,7 +11,8 @@ Install it with the latest T3 Code release: npx t3@latest service install ``` -Check whether it is installed: +Check whether it is installed. On Linux this also checks whether the service is running, enabled +at startup, and allowed to keep running after logout: ```sh npx t3@latest service status @@ -58,6 +59,8 @@ updates roll back with the server version. An older launcher may require one loc **Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts when the machine boots and keeps running after you log out (lingering is enabled during install). +Setup checks the systemd user manager and enables lingering before installing a runtime or stopping +an existing service. If that requires administrator permission, setup stops with a recovery command. **macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no @@ -87,3 +90,34 @@ background. This is only an onboarding shortcut: the service and T3 Connect are Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer want T3 Code to start in the background. + +## Troubleshooting + +Run `t3 service status` on the server machine. An installed version alone does not mean the service +is running or will survive logout. Linux status reports these problems: + +| Code | What it means | Recovery | +| -------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `linger-disabled` | The service stops after your last login session ends and does not start at boot. | Run `sudo loginctl enable-linger "$(id -un)"`, then retry setup as your normal user. | +| `linger-unavailable` | T3 Code could not verify the logout setting. | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | +| `user-manager-unavailable` | T3 Code cannot reach your systemd user manager. | Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if needed. | +| `service-disabled` | The service is not enabled to start automatically. | Run the repair command shown by `t3 service status`. | +| `service-stopped` | The service is installed but is not running. | Read the service log and `systemctl --user status t3code.service`, then run the displayed repair command. | + +For an SSH host, run the administrator command in an interactive terminal so sudo can prompt for +your password: + +```sh +ssh -t your-server 'sudo loginctl enable-linger "$(id -un)"' +``` + +Run only the `loginctl` command with sudo. Running `t3` with sudo creates a separate installation and +Connect identity for root. If an administrator is unavailable, run `t3 serve` in a terminal and +keep that session open. + +The repair command shown by status uses the CLI version, or the installed service version if that +is newer. An older stable CLI therefore does not recommend downgrading a nightly installation. +Setup leaves an existing service running if the user-manager or lingering check fails. + +`t3 service status` prints the log path. The adjacent `server.trace.ndjson` file contains detailed +server traces. For failures after authorization, see [T3 Connect troubleshooting](./remote-access.md#t3-connect-troubleshooting). diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 17e60e8ee4ce..50a07b50fd2b 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -2,6 +2,33 @@ Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. +## T3 Connect troubleshooting + +Run `t3 connect` on the server machine to authorize it and optionally install the background service. +The authorization message means your sign-in was saved. The server must then start and establish its +relay link before the machine is reachable. + +`t3 connect status` reports saved authorization and link configuration, not a live reachability +check. If the machine appears offline, run `t3 service status` on it and read the displayed log. +On Linux, a service that works while SSH is open but stops after logout usually has lingering +disabled. See [background service troubleshooting](./background-service.md#troubleshooting). + +Relay errors include the returned reason and trace ID when available: + +| Error | Next step | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `environment_link_limit_exceeded` / managed tunnel limit reached | Unlink an unused environment in T3 Connect, then restart T3 Code on this machine. | +| `auth_invalid` / `invalid_bearer` | Run `t3 connect login`. If the stored credential was revoked, run `t3 connect logout`, then `t3 connect` and restart the server. | +| Expired or invalid link proof | Check the server's date and time, update T3 Code, and restart it. Include the reason and trace ID if it still fails. | +| HTTP 403 without a recognized error response | Check relay access and any proxy or firewall restrictions. Include the Cloudflare Ray ID if one was returned; an HTTP status alone does not identify the cause. | +| HTTP 408, 429, or 5xx | The server retries temporary failures during startup for up to ten minutes. Check network and relay availability; include the trace ID when reporting a persistent failure. | + +Authorization and other permanent 4xx rejections stop the startup link attempt immediately. +After correcting them, restart the server. For the Linux background service, use +`systemctl --user restart t3code.service`; for a foreground server, stop it and run `t3 serve` again. +Keep the diagnostic message and trace ID when reporting a problem. Do not post authorization codes, +pairing URLs, or the contents of the secrets directory. + ## Quick Pairing for a Running Server If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything: From 4cc800c7593db13726171918572afe3502c43ba6 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:50:44 -0400 Subject: [PATCH 02/18] fix(web): keep command palette above composer menus (#9613) --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8993fbcab89d..0b034d44338c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -758,7 +758,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: return createPortal(
Date: Fri, 4 Sep 2026 13:22:26 +0530 Subject: [PATCH 03/18] fix(web): snooze menu no longer overlaps thread details (#9601) --- apps/web/src/components/Sidebar.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b636dc6ed46..5e5a73c6f565 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1122,7 +1122,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [onSnooze, threadRef], ); // While the snooze popover is open the pointer leaves the row, which - // would fade the hover actions out from under the open menu; pin them. + // would fade the hover actions out from under the open menu. Pin them and + // suppress the row tooltip so its portal cannot overlap the popover. const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); // Snooze is offered only where it can succeed: capability-gated and never // on blocked-on-you work or queued turns (the server rejects both). @@ -1445,7 +1446,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { sortable?.isDragging && "z-20 opacity-80", )} > - + Date: Fri, 4 Sep 2026 00:57:35 -0700 Subject: [PATCH 04/18] fix(web): match composer pull request state icons (#9375) --- .../BranchToolbarBranchSelector.tsx | 6 +++++- apps/web/src/components/LegacySidebar.tsx | 8 ++++++-- .../components/ThreadStatusIndicators.test.ts | 18 ++++++++++++++++++ .../src/components/ThreadStatusIndicators.tsx | 19 ++++++++++++++----- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e968954ec1d0..27bf2ede9b9a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -745,7 +745,11 @@ export function BranchToolbarBranchSelector({ /> } > - +
- {prStatus && ( + {prStatus && pr && ( event.stopPropagation()} onClick={handlePrClick} > - + } /> diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 439756b2466e..21949a1d1808 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -2,8 +2,15 @@ import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3too import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/unstable/reactivity"; +import { + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, +} from "lucide-react"; import { + ChangeRequestStatusIcon, nextThreadChangeRequestSnapshot, prStatusIndicator, resolveDisplayedThreadPr, @@ -16,6 +23,17 @@ import { } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; +describe("ChangeRequestStatusIcon", () => { + it.each([ + ["open", "open", false, GitPullRequestIcon], + ["draft", "open", true, GitPullRequestDraftIcon], + ["closed", "closed", false, GitPullRequestClosedIcon], + ["merged", "merged", false, GitMergeIcon], + ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => { + expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon); + }); +}); + function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 4c830f4a4b8b..e7b24f80cb71 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -11,7 +11,7 @@ import { type VcsStatusResult, } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; @@ -24,6 +24,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -155,8 +156,16 @@ export function prStatusIndicator( return null; } -export function ChangeRequestStatusIcon({ className }: { className?: string }) { - return ; +export function ChangeRequestStatusIcon({ + state, + isDraft = false, + className, +}: Pick, "state"> & { + readonly isDraft?: boolean | undefined; + readonly className?: string | undefined; +}) { + const presentation = resolvePullRequestState({ state, isDraft }); + return ; } export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { @@ -585,7 +594,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar return ( - {prStatus ? ( + {prStatus && pr ? ( } > - + From 2152d44de2db30a6bae965b0afd30be080e5c872 Mon Sep 17 00:00:00 2001 From: Barry <43803274+BarryHenryJr@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:58:56 -1000 Subject: [PATCH 05/18] fix(server): load OpenCode workspace skills via SDK to avoid 64KB CLI pipe truncation (#9585) --- .../src/provider/Drivers/OpenCodeDriver.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 8ff01946644c..08a4d220787b 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -149,6 +149,14 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime, openCodeRuntime), ); + // NOTE: the local branch intentionally uses the shared SDK server + // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes + // its full JSON inventory to stdout, but the Bun-compiled binary does + // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the + // piped output arrives truncated and unparseable — which degrades to an + // empty skill list and poisons the workspace snapshot the `$` picker + // reads. The SDK `app.skills` endpoint honors the per-request directory + // and returns complete results regardless of size. const loadSkillsForCwd = (cwd: string) => effectiveConfig.serverUrl.trim().length > 0 ? Effect.scoped( @@ -172,11 +180,17 @@ export const OpenCodeDriver: ProviderDriver return yield* openCodeRuntime.loadOpenCodeSkills(client); }), ) - : openCodeRuntime.loadSkillsFromCli({ - binaryPath: effectiveConfig.binaryPath, - cwd, - environment: processEnv, - }); + : serverOwner.withServer((server) => + openCodeRuntime.loadOpenCodeSkills( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), + }), + ), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( From ec3ec6f0b4e005c47aff07d4d9e31506241bce3a Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 04:08:08 -0400 Subject: [PATCH 06/18] fix(web): mute sidebar branch name to match worktree icon (#9622) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5e5a73c6f565..d0ededf6ed83 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1608,7 +1608,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {thread.branch ? ( <> - {thread.branch} + + {thread.branch} + ) : ( From 5f878d2a85807618a4c8571cdef5daa3124672d6 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 04:12:46 -0400 Subject: [PATCH 07/18] fix(web,mobile): fold context compaction under settled turn folds (#9623) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/mobile/src/lib/threadActivity.ts | 13 +++++++++--- .../components/chat/MessagesTimeline.logic.ts | 20 +++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6233bdf355d3..a034330006c4 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1375,9 +1375,6 @@ function deriveThreadFeedTurnFolds( pendingUserBoundary = entry.message.createdAt; continue; } - if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) { - continue; - } const turnId = entry.type === "message" && entry.message.role === "assistant" ? entry.message.turnId @@ -1423,6 +1420,16 @@ function deriveThreadFeedTurnFolds( if (hiddenEntryIds.size === 0) { continue; } + // A lone compaction row stays visible on its own; it only folds away as + // part of a turn that already folds other work. + const hidesNonCompactionWork = entries.some( + (entry) => + hiddenEntryIds.has(entry.id) && + !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)), + ); + if (!hidesNonCompactionWork) { + continue; + } const firstEntry = entries[0]; const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id)); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 413b2c0f9075..6883a5c74c76 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -619,17 +619,15 @@ function deriveTurnFolds(input: { if (entry.id === group.terminalEntry?.id) { continue; } - if (index > terminalEntryIndex) { + const isCompaction = + entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"; + if (!isCompaction && index > terminalEntryIndex) { continue; } // Agent-spawn CTA rows never fold: workflows outlive their launching // turn (dynamic spawns, background execution), and folding the CTA // when the turn settles makes a still-running fleet invisible. - if ( - entry.kind === "work" && - (entry.entry.agentSpawn !== undefined || - entry.entry.sourceActivityKind === "context-compaction") - ) { + if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { continue; } hiddenEntryIds.add(entry.id); @@ -637,6 +635,16 @@ function deriveTurnFolds(input: { if (hiddenEntryIds.size === 0) { continue; } + // A lone compaction row stays visible on its own; it only folds away as + // part of a turn that already folds other work. + const hidesNonCompactionWork = group.entries.some( + (entry) => + hiddenEntryIds.has(entry.id) && + !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"), + ); + if (!hidesNonCompactionWork) { + continue; + } const firstEntry = group.entries[0]; const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id)); From 09d13de4381925fa2a6dea74eff8185fa301e905 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:52:07 -0400 Subject: [PATCH 08/18] feat(mobile): make chat text selectable on Android (#8779) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../t3-markdown-text/android/build.gradle | 20 ++++ .../T3MarkdownTextSelectionModule.kt | 93 +++++++++++++++++++ .../t3-markdown-text/expo-module.config.json | 6 ++ .../modules/t3-markdown-text/package.json | 3 + .../src/MarkdownTextPrimitive.tsx | 17 +++- .../src/NativeMarkdownBlock.ios.tsx | 23 +++-- .../src/NativeMarkdownSelectableText.ios.tsx | 65 +++++++++++-- .../src/T3MarkdownTextSelectionModule.ts | 12 +++ .../native/SelectableMarkdownText.android.tsx | 24 +++++ pnpm-lock.yaml | 6 +- 10 files changed, 246 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/android/build.gradle create mode 100644 apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt create mode 100644 apps/mobile/modules/t3-markdown-text/expo-module.config.json create mode 100644 apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts create mode 100644 apps/mobile/src/native/SelectableMarkdownText.android.tsx diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle new file mode 100644 index 000000000000..13584a00be42 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.markdowntext' +version = '0.0.0' + +android { + namespace 'expo.modules.t3markdowntext' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') + implementation 'com.facebook.react:react-android' +} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt new file mode 100644 index 000000000000..af8675831f2d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -0,0 +1,93 @@ +package expo.modules.t3markdowntext + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.text.Spanned +import android.text.style.ReplacementSpan +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.widget.TextView +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import kotlin.math.max +import kotlin.math.min + +private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" + +private fun copyTextWithoutInlineImages( + text: CharSequence, + start: Int, + end: Int +): String { + if (text !is Spanned) return text.subSequence(start, end).toString() + + return buildString { + for (index in start until end) { + val isInlineImage = + text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && + text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() + if (!isInlineImage) append(text[index]) + } + } +} + +private class SanitizingSelectionActionModeCallback( + private val textView: TextView, + private val delegate: ActionMode.Callback? +) : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean = + delegate?.onCreateActionMode(mode, menu) ?: true + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = + delegate?.onPrepareActionMode(mode, menu) ?: false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + if (item.itemId == android.R.id.copy) { + val start = min(textView.selectionStart, textView.selectionEnd) + val end = max(textView.selectionStart, textView.selectionEnd) + if (start >= 0 && end > start) { + val originalText = textView.text.subSequence(start, end).toString() + val selectedText = copyTextWithoutInlineImages(textView.text, start, end) + if (selectedText != originalText) { + val clipboard = + textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedText)) + mode.finish() + return true + } + } + } + return delegate?.onActionItemClicked(mode, item) ?: false + } + + override fun onDestroyActionMode(mode: ActionMode) { + delegate?.onDestroyActionMode(mode) + } +} + +class T3MarkdownTextSelectionModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3MarkdownTextSelection") + + Function("installCopySanitizer") { reactTag: Int -> + val reactContext = appContext.reactContext as? ReactContext ?: return@Function + reactContext.runOnUiQueueThread { + val textView = + runCatching { + UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) + } + .getOrNull() as? TextView ?: return@runOnUiQueueThread + val currentCallback = textView.customSelectionActionModeCallback + if (currentCallback is SanitizingSelectionActionModeCallback) { + return@runOnUiQueueThread + } + textView.customSelectionActionModeCallback = + SanitizingSelectionActionModeCallback(textView, currentCallback) + } + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/expo-module.config.json b/apps/mobile/modules/t3-markdown-text/expo-module.config.json new file mode 100644 index 000000000000..f41f760ee7ca --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"] + } +} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 7ab3f1fbde82..1e52d7695ec6 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -4,7 +4,9 @@ "private": true, "source": "./index.ts", "files": [ + "android", "assets", + "expo-module.config.json", "ios", "src", "index.ts", @@ -28,6 +30,7 @@ "peerDependencies": { "@t3tools/client-runtime": "*", "@t3tools/shared": "*", + "expo": "*", "expo-asset": "*", "expo-clipboard": "*", "expo-haptics": "*", diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 36e87ee94158..0ae9a0f7b178 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { type Ref } from "react"; import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; @@ -33,6 +33,7 @@ export type ContextMenuActionEvent = { * while the React Native Text fallback reports measured `TextLayoutLine`s. */ export type MarkdownTextPrimitiveProps = Omit & { + nativeTextRef?: Ref; uiTextView?: boolean; contextMenuConfig?: string; onContextMenuAction?: (event: ContextMenuActionEvent) => void; @@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit & { onSelectionChange?: (event: SelectionChangeEvent) => void; }; -function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { +function MarkdownTextPrimitiveChild({ + style, + children, + nativeTextRef: _nativeTextRef, + ...rest +}: MarkdownTextPrimitiveProps) { const [isAncestor, rootStyle] = useTextAncestorContext(); // Flatten the styles, and apply the root styles when needed @@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr return <>{nativeChildren}; } -function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { +function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) { const [isAncestor] = useTextAncestorContext(); // Even if the uiTextView prop is set, we can still default to using // normal selection (i.e. base RN text) if the text doesn't need to be // selectable if ((!props.selectable || !props.uiTextView) && !isAncestor) { - return ; + return ; } return ; } export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { if (Platform.OS !== "ios") { - return ; + const { nativeTextRef, ...textProps } = props; + return ; } return ; } diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index b0934e873a7b..348a3c489a2c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState } from "react"; -import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; +import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; @@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); const highlightedCodePromiseCache = new Map>(); const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; +const MONO_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); function nodeKey(node: MarkdownNode, index: number): string { return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; @@ -171,7 +176,7 @@ function HighlightedCodeText(props: { selectable style={{ color: props.textStyle.codeColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontSize: codeBlockFontSize(props.textStyle), lineHeight: codeBlockLineHeight(props.textStyle), }} @@ -206,7 +211,7 @@ function HighlightedCodeText(props: { selectable style={{ color: props.textStyle.codeColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontSize: codeBlockFontSize(props.textStyle), lineHeight: codeBlockLineHeight(props.textStyle), }} @@ -218,7 +223,7 @@ function HighlightedCodeText(props: { key={key} style={{ color: token.color ?? props.textStyle.codeColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontStyle: token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal", fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", @@ -274,7 +279,7 @@ function NativeCodeBlock(props: { style={{ flex: 1, color: props.textStyle.mutedColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontSize: codeBlockFontSize(props.textStyle), }} > @@ -294,6 +299,7 @@ function NativeCodeBlock(props: { @@ -330,7 +336,12 @@ function NativeTable(props: { }) { const rows = collectTableRows(props.node); return ( - + MarkdownFileContextMenu | undefined; @@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ "; const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; const SKILL_ICON_PLACEHOLDER = "\uFFFC"; const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; +const MONO_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); +const styles = StyleSheet.create({ + inlineIcon: { + width: 14, + height: 14, + marginHorizontal: 3, + transform: [{ translateY: 2 }], + }, +}); function runKeySignature(run: NativeMarkdownTextRun): string { return [ @@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle isFile || isSkill ? textStyle.boldFontFamily : run.code || isCodeBlock - ? "ui-monospace" + ? MONO_FONT_FAMILY : isHeading ? textStyle.headingFontFamily : run.bold @@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: { }) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); + const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); + const attachAndroidText = useCallback( + (textView: RNText | null) => { + if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { + return; + } + const reactTag = findNodeHandle(textView); + if (reactTag !== null) { + installMarkdownCopySanitizer(reactTag); + } + }, + [containsInlineFileIcon], + ); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: { occurrences.set(signature, occurrence + 1); let text = run.text; - if (run.fileIcon) { + if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { - text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`; + text = + Platform.OS === "ios" + ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}` + : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); text = `${EXTERNAL_LINK_PREFIX}${text}`; @@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: { return ( + {Platform.OS === "android" && run.fileIcon ? ( + + ) : null} {text} ); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts new file mode 100644 index 000000000000..4df810abd354 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts @@ -0,0 +1,12 @@ +import { requireOptionalNativeModule } from "expo"; + +interface T3MarkdownTextSelectionNativeModule { + readonly installCopySanitizer: (reactTag: number) => void; +} + +const nativeModule = + requireOptionalNativeModule("T3MarkdownTextSelection"); + +export function installMarkdownCopySanitizer(reactTag: number): void { + nativeModule?.installCopySanitizer(reactTag); +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.android.tsx b/apps/mobile/src/native/SelectableMarkdownText.android.tsx new file mode 100644 index 000000000000..a59a039cbc92 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.android.tsx @@ -0,0 +1,24 @@ +import { + SelectableMarkdownText as T3SelectableMarkdownText, + type SelectableMarkdownTextProps, +} from "@t3tools/mobile-markdown-text/renderer"; + +import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + MarkdownImageRequest, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +// The renderer falls back to React Native Text outside iOS, so Android can use +// the same Markdown chunking while retaining native text selection. +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { + return ; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032a1e05ff6d..2a7e45ca8575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,7 +278,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919) + version: file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -4513,6 +4513,7 @@ packages: peerDependencies: '@t3tools/client-runtime': '*' '@t3tools/shared': '*' + expo: '*' expo-asset: '*' expo-clipboard: '*' expo-haptics: '*' @@ -14372,10 +14373,11 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a)': dependencies: '@t3tools/client-runtime': link:packages/client-runtime '@t3tools/shared': link:packages/shared + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-clipboard: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 57.0.2(expo@57.0.18) From 14bf3f6d1644a37029be58429e8f0138e1ceb743 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 04:20:08 -0700 Subject: [PATCH 09/18] fix(web): toggle a single stashed prompt with Cmd+S (#9644) Cmd+S opened the stash menu even when the composer was empty and only one prompt was stashed. It now restores that prompt directly, so repeated presses toggle between the draft and stash. Multiple entries and images that are still saving open the menu. The stash badge still opens the menu. Validation: 94 focused stash, shortcut, and attachment tests pass. Web typecheck and formatting pass. Targeted lint has no new warnings or errors. Browser checks were skipped at Theo's request. Original implementation by Theo Browne. No code changes were needed during the takeover audit. Audited with GPT-6 Astra (preview) in Codex. --- apps/web/src/components/chat/ChatComposer.tsx | 9 ++++++++- apps/web/src/components/chat/ComposerStashMenu.tsx | 8 ++++---- docs/user/composer.md | 6 ++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0b034d44338c..946d150bb0ac 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3336,7 +3336,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const images = [...composerImagesRef.current]; const files = [...composerFilesRef.current]; if (prompt.length === 0 && images.length === 0 && files.length === 0) { - setIsStashMenuOpen((open) => !open); + const entries = usePromptStashStore.getState().entries; + const entry = entries.length === 1 ? entries[0] : undefined; + if (entry && !entry.pendingImageCount) { + await restoreStashEntry(entry); + } else { + setIsStashMenuOpen((open) => !open); + } return; } const stashedFiles: PersistedComposerFileAttachment[] = []; @@ -3523,6 +3529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) finalizeStashEntryImages, promptRef, pulseStashBadge, + restoreStashEntry, stashEntryToQueue, ]); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 69032764c1f4..4942b21dd600 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -30,10 +30,10 @@ function stashEntrySnippet(entry: PromptStashEntry): string { } /** - * Attached banner listing the stashed prompts. Keyboard-first: opened by ⌘S on an - * empty composer, navigated with arrows, restored with Enter, dismissed - * with Escape. The listener runs capture-phase on window so it wins over - * the Lexical editor's handlers while the menu is open. + * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S + * when the empty composer cannot restore a single entry. Navigated with arrows, + * restored with Enter, dismissed with Escape. The listener runs capture-phase + * on window so it wins over the Lexical editor's handlers while the menu is open. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; diff --git a/docs/user/composer.md b/docs/user/composer.md index fce133cd27d8..2e98a21f7198 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -182,8 +182,10 @@ open the stack. Interacting with the attached banner or composer does not open t ## Prompt stash Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current -prompt and its attachments after all file uploads finish. Restore the entry later from the stash -menu. Stashes that contain files must be restored in the environment where those files were +prompt and its attachments after all file uploads finish. When the composer is empty and the stash +has one entry, press the shortcut again to restore it. The shortcut opens the stash menu if there +are multiple entries or the entry's images are still saving. You can also open the menu from the +stash badge. Stashes that contain files must be restored in the environment where those files were uploaded. Stashed files stay uploaded on the server for 24 hours. If you restore an entry after that, the file comes back with **Attach again** next to it. Attach the file again or remove it, then send. From eb77683e5544e071db74831bae052bbd8a7d5f88 Mon Sep 17 00:00:00 2001 From: seeb1337 <63622047+seeb1337@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:20:53 +0200 Subject: [PATCH 10/18] fix(server): prevent duplicate desktop clients after restart Replace stale local desktop sessions in one transaction. Preserve paired clients and browser sessions, and keep the previous credential valid if replacement fails. Closes https://github.com/pingdotgg/t3code/issues/6283. Original implementation by seeb1337. Reviewed and verified with GPT-6 Astra (preview) in Codex. Co-authored-by: seeb1337 <63622047+seeb1337@users.noreply.github.com> Co-authored-by: Theo Browne --- apps/server/src/auth/EnvironmentAuth.test.ts | 77 ++++++++++++++++++++ apps/server/src/auth/EnvironmentAuth.ts | 3 + apps/server/src/auth/SessionStore.test.ts | 74 +++++++++++++++++++ apps/server/src/auth/SessionStore.ts | 57 ++++++++++----- apps/server/src/persistence/AuthSessions.ts | 49 +++++++++++++ apps/server/src/server.test.ts | 32 ++++++++ docs/internals/environment-auth.md | 6 ++ docs/user/remote-access.md | 4 + 8 files changed, 284 insertions(+), 18 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 6e5f22fa3af6..028fe53e0191 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -52,6 +52,18 @@ const makeCookieRequest = ( EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] >[0]; +const makeBearerRequest = ( + token: string, +): Parameters[0] => + ({ + cookies: {}, + headers: { + authorization: `Bearer ${token}`, + }, + }) as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + const requestMetadata = { deviceType: "desktop" as const, os: "macOS", @@ -159,6 +171,71 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const browser = yield* serverAuth.createBrowserSession( + "desktop-bootstrap-token", + requestMetadata, + ); + const browserSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(sessions.cookieName, browser.sessionToken), + ); + const staleSessions = yield* Effect.forEach([1, 2, 3], () => + sessions.issue({ subject: "desktop-bootstrap", method: "bearer-access-token" }), + ); + const pairing = yield* serverAuth.issuePairingCredential(); + const paired = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + pairing.credential, + undefined, + { ...requestMetadata, label: "T3 Code Desktop" }, + ); + const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + "desktop-bootstrap-token", + undefined, + requestMetadata, + ); + const firstSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(first.access_token), + ); + const second = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + "desktop-bootstrap-token", + undefined, + requestMetadata, + ); + + const active = yield* serverAuth.listSessions(); + const firstError = yield* serverAuth + .authenticateHttpRequest(makeBearerRequest(first.access_token)) + .pipe(Effect.flip); + const secondSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(second.access_token), + ); + + expect(active).toHaveLength(3); + expect(active.map((entry) => entry.sessionId)).toContain(browserSession.sessionId); + expect(active.map((entry) => entry.sessionId)).toContain(secondSession.sessionId); + expect(active.map((entry) => entry.sessionId)).not.toContain(firstSession.sessionId); + expect(firstError._tag).toBe("ServerAuthInvalidCredentialError"); + for (const stale of staleSessions) { + const error = yield* sessions.verify(stale.token).pipe(Effect.flip); + expect(error._tag).toBe("SessionTokenRevokedError"); + } + const pairedSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(paired.access_token), + ); + expect(pairedSession.subject).toBe("one-time-token"); + expect(active.map((entry) => entry.sessionId)).toContain(pairedSession.sessionId); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); + it.effect("keeps user-issued administrative pairing links manageable", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 2d0f02274de9..b0406b6e6ecd 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { ttl: Duration.hours(1), } : {}), + // Desktop restarts forget the previous bearer token. Replace + // its session, including stale entries left by older versions. + replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", client: { ...requestMetadata, ...(grant.label ? { label: grant.label } : {}), diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index aa3b2d199148..fa87c5ce4e84 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -50,6 +51,7 @@ const repositoryFailure = new PersistenceSqlError({ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, + createReplacingActive: () => Effect.succeed([]), getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), revoke: () => Effect.fail(repositoryFailure), @@ -181,6 +183,78 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("atomically replaces active sessions with the same subject and method", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const browser = yield* sessions.issue({ + subject: "desktop-bootstrap", + method: "browser-session-cookie", + }); + const [firstBearer, secondBearer] = yield* Effect.all( + [ + sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }), + sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }), + ], + { concurrency: "unbounded" }, + ); + + const active = yield* sessions.listActive(); + const bearerVerification = yield* Effect.all([ + sessions.verify(firstBearer.token).pipe(Effect.option), + sessions.verify(secondBearer.token).pipe(Effect.option), + ]); + + expect(active).toHaveLength(2); + expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined(); + expect( + active.filter( + (entry) => + entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token", + ), + ).toHaveLength(1); + expect(bearerVerification.filter(Option.isSome)).toHaveLength(1); + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); + + it.effect("keeps the previous desktop session valid when replacement fails", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const previous = yield* sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + }); + yield* sql` + CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions + BEGIN + SELECT RAISE(ABORT, 'simulated insert failure'); + END + `; + + const error = yield* sessions + .issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SessionCredentialIssueError"); + expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); + expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([ + previous.sessionId, + ]); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); + it.effect("rejects websocket tokens once the parent session has expired", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index d4fbe445edf6..b315bdf87c7f 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; + /** + * Atomically revoke active sessions with the same subject and method + * before storing this session. + */ + readonly replaceActiveForSubjectAndMethod?: boolean; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; readonly issueWebSocketToken: ( @@ -647,24 +652,40 @@ export const make = Effect.gen(function* () { ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions - .create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }) - .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + const sessionRecord = { + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + } satisfies AuthSessions.CreateAuthSessionInput; + const replacedSessionIds = yield* ( + input?.replaceActiveForSubjectAndMethod + ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) + : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray)) + ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + if (replacedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const replacedSessionId of replacedSessionIds) { + next.delete(replacedSessionId); + } + return next; + }); + yield* Effect.forEach(replacedSessionIds, emitRemoved, { + concurrency: "unbounded", + discard: true, + }); + } yield* emitUpsert( toAuthClientSession({ sessionId, diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 579d3a608190..b47f148f0761 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ }); export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; +export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ + session: CreateAuthSessionInput, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type CreateReplacingActiveAuthSessionInput = + typeof CreateReplacingActiveAuthSessionInput.Type; + export const GetAuthSessionByIdInput = Schema.Struct({ sessionId: AuthSessionId, }); @@ -96,6 +103,9 @@ export class AuthSessionRepository extends Context.Service< readonly create: ( input: CreateAuthSessionInput, ) => Effect.Effect; + readonly createReplacingActive: ( + input: CreateReplacingActiveAuthSessionInput, + ) => Effect.Effect, AuthSessionRepositoryError>; readonly getById: ( input: GetAuthSessionByIdInput, ) => Effect.Effect, AuthSessionRepositoryError>; @@ -254,6 +264,21 @@ export const make = Effect.gen(function* () { `, }); + const revokeActiveSessionsForReplacement = SqlSchema.findAll({ + Request: CreateReplacingActiveAuthSessionInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ session, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE subject = ${session.subject} + AND method = ${session.method} + AND revoked_at IS NULL + AND expires_at > ${revokedAt} + RETURNING session_id AS "sessionId" + `, + }); + const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, Result: AuthSessionRawDbRow, @@ -343,6 +368,29 @@ export const make = Effect.gen(function* () { ), ); + const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = ( + input, + ) => + sql + .withTransaction( + revokeActiveSessionsForReplacement(input).pipe( + Effect.flatMap((revokedRows) => + createSessionRow(input.session).pipe( + Effect.as(revokedRows.map((row) => row.sessionId)), + ), + ), + ), + ) + .pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.createReplacingActive:query", + "AuthSessionRepository.createReplacingActive:encodeRequest", + { sessionId: input.session.sessionId }, + ), + ), + ); + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( @@ -442,6 +490,7 @@ export const make = Effect.gen(function* () { return { create, + createReplacingActive, getById, listActive, revoke, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 85afe00cb52a..07aee5e861d3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("replaces the local desktop credential on repeated bootstrap exchanges", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const first = yield* exchangeAccessToken(); + const second = yield* exchangeAccessToken(); + const third = yield* exchangeAccessToken(); + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 200); + assert.equal(third.response.status, 200); + + const clientsResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { authorization: `Bearer ${third.body.access_token}` }, + }); + const clients = (yield* clientsResponse.json) as ReadonlyArray<{ + readonly current: boolean; + readonly subject: string; + }>; + assert.equal(clientsResponse.status, 200); + assert.equal(clients.length, 1); + assert.equal(clients[0]?.current, true); + assert.equal(clients[0]?.subject, "desktop-bootstrap"); + + for (const previous of [first, second]) { + const response = yield* HttpClient.get("/api/auth/session", { + headers: { authorization: `Bearer ${previous.body.access_token}` }, + }); + const state = (yield* response.json) as { readonly authenticated: boolean }; + assert.equal(state.authenticated, false); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("persists token exchange client display metadata for authorized-client listings", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 98b3df0a0dc8..068fcfa68a90 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's only to DPoP-bound exchanges, where the token is additionally constrained by a proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. +The reusable `desktop-bootstrap` grant replaces active sessions with the same +subject and authentication method. Revocation and insertion share one database +transaction, so a failed insertion preserves the previous credential. This also +removes stale local desktop entries from earlier launches. Browser-cookie sessions +and sessions issued through pairing links are not replaced. + Requested scopes must be a subset of the one-time bootstrap credential grant. An ordinary paired client therefore cannot exchange its grant for `access:read`, `access:write`, or `relay:write`. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 50a07b50fd2b..b2b540a83e2c 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. +After an app restart, the desktop app replaces its previous +local credential. Old local desktop entries are removed from **Authorized clients** +automatically. Paired phones, browsers, and remote desktop clients keep their access. + When no user default is saved, the app uses the built-in LAN endpoint for pairing links when available. You can set another endpoint as the default from the expanded endpoint list. From d487dfbf46be344e818725be70ee04be2436bfb4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 04:25:32 -0700 Subject: [PATCH 11/18] fix(web): resume Antigravity threads without repeated sign-in (#9647) Allow Antigravity threads to resume while saved Google sign-in is unchecked after a server restart. Keep confirmed authentication failures and installation errors visible. Validated with 136 focused tests, web typecheck, targeted lint, and CI. Browser verification was omitted at the maintainer's request. Created with GPT-6 Astra (preview) in Codex. --- .../web/src/components/ChatView.logic.test.ts | 15 +++++--- apps/web/src/components/ChatView.logic.ts | 9 +++-- .../chat/ProviderStatusBanner.test.tsx | 36 +++++++++++++++++++ .../components/chat/ProviderStatusBanner.tsx | 19 +++++++--- docs/user/providers-antigravity.md | 12 ++++--- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8e9c80641f2b..c8afd10d3725 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, CheckpointRef, EnvironmentId, MessageId, @@ -793,14 +794,20 @@ describe("resolveComposerProviderSelection", () => { ); }); - it("blocks sends until Antigravity confirms authentication", () => { + it("lets Antigravity check saved credentials when resuming after a restart", () => { const provider = entry("antigravity", "google_work", { + status: "warning", auth: { status: "unknown" }, - models: catalogModels, + models: [], }).snapshot; - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( - "Sign in to Antigravity in provider settings before sending.", + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); + expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); + expect( + getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"), + ).toBeNull(); + expect(getAntigravitySendBlockReason(provider, "")).toBe( + "Choose an Antigravity model before sending.", ); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1a6b1b775f41..4a0b9f576103 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -421,14 +421,17 @@ export function getAntigravitySendBlockReason( if (!provider.installed) { return "Install Antigravity in provider settings before sending."; } - if (provider.auth.status !== "authenticated") { + if (provider.auth.status === "unauthenticated") { return "Sign in to Antigravity in provider settings before sending."; } + const slug = model.trim(); + if (slug.length === 0) return "Choose an Antigravity model before sending."; + // A restart clears the account status and catalog. Session startup checks + // saved credentials and validates the model before sending the prompt. + if (provider.auth.status === "unknown") return null; if (provider.models.length === 0) { return "Refresh Antigravity models in provider settings before sending."; } - const slug = model.trim(); - if (slug.length === 0) return "Choose an Antigravity model before sending."; // A saved model that left the catalog is kept in the picker as unavailable // so the user sees what the thread used. The server rejects it at turn // start, so block here unless the provider is in an error state, where a diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index f27cda19d957..e51383bc69fe 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -28,6 +28,42 @@ function warningProvider(): ServerProvider { } describe("ProviderStatusBanner", () => { + it("waits for an Antigravity auth result before showing a sign-in warning", () => { + const status: ServerProvider = { + ...warningProvider(), + instanceId: ProviderInstanceId.make("google_work"), + driver: ProviderDriverKind.make("antigravity"), + auth: { status: "unknown" }, + message: "Antigravity is installed. Google account access is not checked yet.", + }; + + expect(shouldShowProviderStatusBanner(status, null)).toBe(false); + expect( + shouldShowProviderStatusBanner( + { + ...status, + auth: { status: "unauthenticated" }, + message: "Sign in with Google to use Antigravity.", + }, + null, + ), + ).toBe(true); + }); + + it("shows Antigravity installation and startup failures before auth is checked", () => { + const status: ServerProvider = { + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + auth: { status: "unknown" }, + }; + + expect(shouldShowProviderStatusBanner({ ...status, installed: false }, null)).toBe(true); + expect(shouldShowProviderStatusBanner({ ...status, status: "error" }, null)).toBe(true); + expect( + shouldShowProviderStatusBanner({ ...status, driver: ProviderDriverKind.make("codex") }, null), + ).toBe(true); + }); + it("stays hidden after its current warning is dismissed", () => { const status = warningProvider(); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 2ae4bc8b58d4..12f9dc04f26a 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -7,9 +7,20 @@ import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { - return !status || status.status === "ready" || status.status === "disabled" - ? null - : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000"); + if (!status || status.status === "ready" || status.status === "disabled") return null; + // Antigravity checks saved credentials when a session starts. Its local + // health check leaves auth unknown after a restart, which is not a failure. + if ( + status.driver === "antigravity" && + status.installed && + status.status === "warning" && + status.auth.status === "unknown" + ) { + return null; + } + return [status.instanceId, status.status, status.auth.status, status.message ?? ""].join( + "\u0000", + ); } export function shouldShowProviderStatusBanner( @@ -59,7 +70,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; status: ServerProvider | null; }) { - if (!status || status.status === "ready" || status.status === "disabled") { + if (!status || getProviderStatusBannerKey(status) === null) { return null; } diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index bd84ced3a395..24fe8c05780e 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -179,10 +179,14 @@ paid-plan tier or remaining subscription quota. See Google's [Antigravity plans] [personal Google sign-in guide][google-setup]. After an environment restarts, Google sign-in can show as not checked until an authenticated -session succeeds. To check account access and reload models, use **Refresh provider status** -in web or desktop provider settings, or **Refresh models** in the mobile model picker. Refresh -uses saved Google sign-in and does not open a login page. If sign-in is required, use the -provider's setup controls. Automatic status checks verify the installation only. +session succeeds. You can continue an existing thread. Antigravity checks saved Google sign-in +when the session starts. An unchecked status does not require signing in again. + +To check account access and reload models on web or desktop, open **Settings** > **Providers** +and select the circular arrow beside **Checked** at the top of the page. Its tooltip says +**Refresh provider status**. On mobile, use **Refresh models** in the model picker. +Refresh uses saved Google sign-in and does not open a login page. If sign-in is required, +use the provider's setup controls. Automatic status checks verify the installation only. The packaged runtime can be slow to start, especially on Windows. Health checks, model refresh, and sign-out each allow up to 90 seconds before reporting a timeout. From d5b94100863057fb4629f9ad4a35753d16917924 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:41:57 +0200 Subject: [PATCH 12/18] feat(mobile): paste the phone clipboard into the terminal (#9199) Co-authored-by: Jake Leventhal Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../terminal/ThreadTerminalRouteScreen.tsx | 138 ++++++++++++------ .../features/terminal/terminalInput.test.ts | 125 ++++++++++++++++ .../src/features/terminal/terminalInput.ts | 108 ++++++++++++++ .../features/terminal/terminalMenu.test.ts | 1 + .../features/terminal/terminalPaste.test.ts | 82 +++++++++++ .../src/features/terminal/terminalPaste.ts | 60 ++++++++ .../src/state/terminalSession.test.ts | 38 +++++ .../src/state/terminalSession.ts | 16 +- 8 files changed, 520 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/features/terminal/terminalInput.test.ts create mode 100644 apps/mobile/src/features/terminal/terminalInput.ts create mode 100644 apps/mobile/src/features/terminal/terminalPaste.test.ts create mode 100644 apps/mobile/src/features/terminal/terminalPaste.ts diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index a51e084efc9e..351082580d63 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -6,6 +6,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as Schema from "effect/Schema"; import { KeyboardController, KeyboardEvents, @@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useServerConfigs } from "../../state/entities"; import { useWorkspaceState } from "../../state/workspace"; import { MAX_TERMINAL_FONT_SIZE, @@ -65,6 +68,13 @@ import { resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; +import { + hostPlatformFromOs, + resolveModifiedTerminalInput, + type HostPlatform, + type PendingModifier, +} from "./terminalInput"; +import { createTerminalPasteSession } from "./terminalPaste"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80; @@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; -type PendingModifier = "ctrl" | "meta"; -type HostPlatform = "mac" | "linux" | "windows" | "unknown"; +class TerminalClipboardReadError extends Schema.TaggedErrorClass()( + "TerminalClipboardReadError", + { terminalId: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Failed to read the clipboard for a paste into terminal ${this.terminalId}.`; + } +} type TerminalToolbarAction = | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } | { readonly kind: "clear"; readonly key: string; readonly label: string } + | { readonly kind: "paste"; readonly key: string; readonly label: string } | { readonly kind: "modifier"; readonly key: string; @@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform { return "unknown"; } -function applyCtrlModifier(input: string): string { - const firstCharacter = input[0]; - if (!firstCharacter) { - return input; - } - - const lowerCharacter = firstCharacter.toLowerCase(); - if (lowerCharacter >= "a" && lowerCharacter <= "z") { - return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); - } - - if (firstCharacter === "@") return "\u0000"; - if (firstCharacter === "[") return "\u001b"; - if (firstCharacter === "\\") return "\u001c"; - if (firstCharacter === "]") return "\u001d"; - if (firstCharacter === "^") return "\u001e"; - if (firstCharacter === "_") return "\u001f"; - if (firstCharacter === "?") return "\u007f"; - - return input; -} - function pickRunningTerminalSessionForBootstrap( sessions: ReadonlyArray, ): KnownTerminalSession | null { @@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); }, [terminal.buffer, terminal.buffer.length, terminalKey]); const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; + const serverConfigs = useServerConfigs(); + const hostOs = + routeEnvironmentId === null + ? null + : (serverConfigs.get(routeEnvironmentId)?.environment.platform.os ?? null); + // The descriptor is authoritative; the label is only a hint until it arrives. const hostPlatform = useMemo( - () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), - [selectedEnvironmentConnection?.environmentLabel], + () => + hostPlatformFromOs(hostOs) ?? + inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), + [hostOs, selectedEnvironmentConnection?.environmentLabel], ); const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); @@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { kind: "send", key: "esc", label: "esc", data: "\u001b" }, ...modifierActions, { kind: "send", key: "tab", label: "tab", data: "\t" }, + { kind: "paste", key: "paste", label: "paste" }, { kind: "clear", key: "clear", label: "clear" }, { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, @@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setHasMeasuredSurface(true); }, [routeEnvironmentId, routeThreadId, terminalId]); + /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ const writeInput = useCallback( - (data: string) => { + async (data: string): Promise => { if (!selectedThread || !isRunning) { - return; + return false; } - void writeTerminal({ + const result = await writeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, @@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) data, }, }); + return result._tag === "Success"; }, [isRunning, selectedThread, terminalId, writeTerminal], ); + const pasteSessionRef = useRef | null>(null); + if (pasteSessionRef.current === null) { + pasteSessionRef.current = createTerminalPasteSession(); + } + const pasteSession = pasteSessionRef.current; + + // Drop delayed clipboard reads whenever the route or attached pty changes. + useEffect(() => { + pasteSession.reset(isRunning); + return () => { + pasteSession.reset(false); + }; + }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); + + const pasteFromClipboard = useCallback(async () => { + await pasteSession.paste({ + readText: Clipboard.getStringAsync, + write: writeInput, + onReadError: (cause) => { + console.error(new TerminalClipboardReadError({ terminalId, cause })); + }, + }); + }, [pasteSession, terminalId, writeInput]); + + /** Sends a key through the armed toolbar modifier, if any, and disarms it. */ + const writeModifiedInput = useCallback( + (data: string) => { + if (pendingModifier === null) { + void writeInput(data); + return; + } + + setPendingModifierState({ terminalId, value: null }); + const resolved = resolveModifiedTerminalInput({ + data, + modifier: pendingModifier, + hostPlatform, + }); + if (resolved.kind === "paste") { + void pasteFromClipboard(); + return; + } + void writeInput(resolved.data); + }, + [hostPlatform, pasteFromClipboard, pendingModifier, terminalId, writeInput], + ); + const handleInput = useCallback( (data: string) => { if (data.length === 0) { return; } - if (pendingModifier === "ctrl") { - setPendingModifierState({ terminalId, value: null }); - writeInput(applyCtrlModifier(data)); - } else if (pendingModifier === "meta") { - setPendingModifierState({ terminalId, value: null }); - writeInput(`\u001b${data}`); - } else { - writeInput(data); - } + writeModifiedInput(data); }, - [pendingModifier, terminalId, writeInput], + [writeModifiedInput], ); const handleResize = useCallback( @@ -1021,16 +1066,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) return; } - setPendingModifierState({ terminalId, value: null }); - if (pendingModifier === "ctrl") { - writeInput(applyCtrlModifier(action.data)); - } else if (pendingModifier === "meta") { - writeInput(`\u001b${action.data}`); - } else { - writeInput(action.data); + if (action.kind === "paste") { + setPendingModifierState({ terminalId, value: null }); + void pasteFromClipboard(); + return; } + + writeModifiedInput(action.data); }, - [handleClearTerminal, pendingModifier, terminalId, writeInput], + [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput], ); const handleDismissKeyboard = useCallback(() => { diff --git a/apps/mobile/src/features/terminal/terminalInput.test.ts b/apps/mobile/src/features/terminal/terminalInput.test.ts new file mode 100644 index 000000000000..aebc00649cec --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalInput.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyCtrlModifier, + chunkTerminalWrite, + encodeTerminalPaste, + hostPlatformFromOs, + resolveModifiedTerminalInput, + TERMINAL_WRITE_MAX_LENGTH, +} from "./terminalInput"; + +const byte = (code: number) => String.fromCharCode(code); +const ESC = byte(0x1b); +const CTRL_C = byte(0x03); +const CTRL_V = byte(0x16); + +describe("applyCtrlModifier", () => { + it("maps letters to control bytes regardless of case", () => { + expect(applyCtrlModifier("c")).toBe(CTRL_C); + expect(applyCtrlModifier("C")).toBe(CTRL_C); + expect(applyCtrlModifier("z")).toBe(byte(0x1a)); + }); + + it("maps the punctuation control keys and leaves the rest untouched", () => { + expect(applyCtrlModifier("[")).toBe(ESC); + expect(applyCtrlModifier("?")).toBe(byte(0x7f)); + expect(applyCtrlModifier("1")).toBe("1"); + expect(applyCtrlModifier("")).toBe(""); + }); +}); + +describe("resolveModifiedTerminalInput", () => { + it("pastes on ctrl+v for windows, linux, and unknown hosts", () => { + for (const hostPlatform of ["windows", "linux", "unknown"] as const) { + expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({ + kind: "paste", + }); + expect(resolveModifiedTerminalInput({ data: "V", modifier: "ctrl", hostPlatform })).toEqual({ + kind: "paste", + }); + } + }); + + it("keeps alt+v as a meta chord on non-mac hosts", () => { + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "windows" }), + ).toEqual({ kind: "write", data: `${ESC}v` }); + }); + + it("pastes on cmd+v and forwards raw ctrl+v on mac hosts", () => { + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "mac" }), + ).toEqual({ kind: "paste" }); + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform: "mac" }), + ).toEqual({ kind: "write", data: CTRL_V }); + }); + + it("still encodes every other modified key", () => { + expect( + resolveModifiedTerminalInput({ data: "c", modifier: "ctrl", hostPlatform: "windows" }), + ).toEqual({ kind: "write", data: CTRL_C }); + expect( + resolveModifiedTerminalInput({ data: "[A", modifier: "meta", hostPlatform: "linux" }), + ).toEqual({ kind: "write", data: `${ESC}[A` }); + }); +}); + +describe("encodeTerminalPaste", () => { + it("passes single-line text through unchanged", () => { + expect(encodeTerminalPaste("git switch -c fix/paste")).toBe("git switch -c fix/paste"); + expect(encodeTerminalPaste("")).toBe(""); + }); + + it("turns LF and CRLF line breaks into a single carriage return each", () => { + expect(encodeTerminalPaste("one\ntwo\r\nthree\n")).toBe("one\rtwo\rthree\r"); + }); + + it("replaces unsafe control bytes with spaces but keeps tabs", () => { + expect(encodeTerminalPaste(`a${byte(0)}b${ESC}c${byte(0x7f)}d\te`)).toBe("a b c d\te"); + }); + + it("never lets a bracketed-paste end marker reach the shell", () => { + expect(encodeTerminalPaste(`safe${ESC}[201~; rm -rf /\n`)).toBe("safe [201~; rm -rf /\r"); + }); +}); + +describe("chunkTerminalWrite", () => { + it("leaves writes within the wire limit whole", () => { + expect(chunkTerminalWrite("")).toEqual([]); + expect(chunkTerminalWrite("ls")).toEqual(["ls"]); + expect(chunkTerminalWrite("x".repeat(TERMINAL_WRITE_MAX_LENGTH))).toHaveLength(1); + }); + + it("splits oversized writes so every chunk fits the contract", () => { + const chunks = chunkTerminalWrite("y".repeat(TERMINAL_WRITE_MAX_LENGTH * 2 + 5)); + expect(chunks.map((chunk) => chunk.length)).toEqual([ + TERMINAL_WRITE_MAX_LENGTH, + TERMINAL_WRITE_MAX_LENGTH, + 5, + ]); + expect(chunks.join("")).toHaveLength(TERMINAL_WRITE_MAX_LENGTH * 2 + 5); + }); + + it("does not cut a surrogate pair in half at the boundary", () => { + const data = `${"z".repeat(TERMINAL_WRITE_MAX_LENGTH - 1)}😀tail`; + const chunks = chunkTerminalWrite(data); + expect(chunks[0]).toHaveLength(TERMINAL_WRITE_MAX_LENGTH - 1); + expect(chunks[1]).toBe("😀tail"); + expect(chunks.join("")).toBe(data); + }); +}); + +describe("hostPlatformFromOs", () => { + it("maps the descriptor os onto the toolbar layout", () => { + expect(hostPlatformFromOs("darwin")).toBe("mac"); + expect(hostPlatformFromOs("windows")).toBe("windows"); + expect(hostPlatformFromOs("linux")).toBe("linux"); + }); + + it("defers to the caller when the os is unknown or not loaded yet", () => { + expect(hostPlatformFromOs("unknown")).toBeNull(); + expect(hostPlatformFromOs(null)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalInput.ts b/apps/mobile/src/features/terminal/terminalInput.ts new file mode 100644 index 000000000000..d7cd60dd7566 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalInput.ts @@ -0,0 +1,108 @@ +import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; + +export type PendingModifier = "ctrl" | "meta"; +export type HostPlatform = "mac" | "linux" | "windows" | "unknown"; + +/** Upper bound of `TerminalWriteInput.data`; longer writes are rejected by the server. */ +export const TERMINAL_WRITE_MAX_LENGTH = 65_536; + +export type ModifiedTerminalInput = + | { readonly kind: "write"; readonly data: string } + | { readonly kind: "paste" }; + +// C0 controls other than tab, LF, and CR, plus DEL. +// eslint-disable-next-line no-control-regex -- Pasted text must not carry raw terminal controls. +const UNSAFE_PASTE_BYTES = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; + +/** + * Encodes a key pressed while the toolbar's one-shot ctrl modifier is armed + * into the control byte a terminal expects. + */ +export function applyCtrlModifier(input: string): string { + const firstCharacter = input[0]; + if (!firstCharacter) { + return input; + } + + const lowerCharacter = firstCharacter.toLowerCase(); + if (lowerCharacter >= "a" && lowerCharacter <= "z") { + return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); + } + + if (firstCharacter === "@") return "\u0000"; + if (firstCharacter === "[") return "\u001b"; + if (firstCharacter === "\\") return "\u001c"; + if (firstCharacter === "]") return "\u001d"; + if (firstCharacter === "^") return "\u001e"; + if (firstCharacter === "_") return "\u001f"; + if (firstCharacter === "?") return "\u007f"; + + return input; +} + +/** + * Resolves what a keypress means once a toolbar modifier is armed. The host's + * paste chord (cmd+v on a macOS host, ctrl+v elsewhere) pastes the device + * clipboard instead of reaching the remote shell as a raw control byte, which + * matches what the web terminal does with the same chord. Forwarding the byte + * is never what a phone user means: PowerShell binds ctrl+v to paste from the + * host machine's clipboard, so the shell inserts whatever the desktop last + * copied rather than the text on the phone. + */ +export function resolveModifiedTerminalInput(input: { + readonly data: string; + readonly modifier: PendingModifier; + readonly hostPlatform: HostPlatform; +}): ModifiedTerminalInput { + const pasteModifier: PendingModifier = input.hostPlatform === "mac" ? "meta" : "ctrl"; + if (input.modifier === pasteModifier && input.data.toLowerCase() === "v") { + return { kind: "paste" }; + } + + return { + kind: "write", + data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, + }; +} + +/** + * Encodes clipboard text for the remote pty the way the web terminal does when + * bracketed paste is off: unsafe control bytes become spaces (which also + * defuses an embedded bracketed-paste end marker, since its ESC goes too) and + * line breaks become carriage returns, since a bare LF is Ctrl+J to a raw-mode + * TUI. The native mobile surface does not expose DECSET 2004, so mobile never + * wraps a paste in bracketed-paste markers. + */ +export function encodeTerminalPaste(text: string): string { + return text.replace(UNSAFE_PASTE_BYTES, " ").replace(/\r\n|\n/g, "\r"); +} + +/** + * Splits terminal input into writes the wire contract accepts, never cutting + * through a surrogate pair so every chunk stays valid UTF-16. + */ +export function chunkTerminalWrite(data: string): ReadonlyArray { + const chunks: string[] = []; + let start = 0; + while (start < data.length) { + let end = Math.min(start + TERMINAL_WRITE_MAX_LENGTH, data.length); + const last = data.charCodeAt(end - 1); + if (end < data.length && last >= 0xd800 && last <= 0xdbff) { + end -= 1; + } + chunks.push(data.slice(start, end)); + start = end; + } + return chunks; +} + +/** + * Maps the OS reported by the environment descriptor onto the toolbar's host + * layout. Returns null for "unknown" so callers can fall back to a weaker signal. + */ +export function hostPlatformFromOs(os: ExecutionEnvironmentPlatformOs | null): HostPlatform | null { + if (os === "darwin") return "mac"; + if (os === "linux") return "linux"; + if (os === "windows") return "windows"; + return null; +} diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 966312270951..bbb16a081454 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -61,6 +61,7 @@ function makeKnownSession(input: { hasRunningSubprocess: false, updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", version: 1, + lifecycleVersion: 1, }, }; } diff --git a/apps/mobile/src/features/terminal/terminalPaste.test.ts b/apps/mobile/src/features/terminal/terminalPaste.test.ts new file mode 100644 index 000000000000..ec9bd9d74d93 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPaste.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { TERMINAL_WRITE_MAX_LENGTH } from "./terminalInput"; +import { createTerminalPasteSession } from "./terminalPaste"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("terminal paste session", () => { + it("drops a clipboard read when the pty restarts in place", async () => { + const session = createTerminalPasteSession(); + session.reset(true); + const clipboardRead = deferred(); + const writes: string[] = []; + + const paste = session.paste({ + readText: () => clipboardRead.promise, + write: async (data) => { + writes.push(data); + return true; + }, + onReadError: () => undefined, + }); + + session.reset(true); + clipboardRead.resolve("stale"); + await paste; + + expect(writes).toEqual([]); + }); + + it("never overlaps writes from rapid paste requests", async () => { + const session = createTerminalPasteSession(); + session.reset(true); + + const firstWrite = deferred(); + const firstWriteStarted = deferred(); + const writes: string[] = []; + let activeWrites = 0; + let maximumActiveWrites = 0; + const write = async (data: string) => { + writes.push(data); + activeWrites += 1; + maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites); + if (writes.length === 1) { + firstWriteStarted.resolve(); + await firstWrite.promise; + } + activeWrites -= 1; + return true; + }; + + const olderPaste = session.paste({ + readText: async () => "a".repeat(TERMINAL_WRITE_MAX_LENGTH + 1), + write, + onReadError: () => undefined, + }); + await firstWriteStarted.promise; + + const newerPaste = session.paste({ + readText: async () => "newer", + write, + onReadError: () => undefined, + }); + await Promise.resolve(); + + expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH]); + expect(maximumActiveWrites).toBe(1); + + firstWrite.resolve(true); + await Promise.all([olderPaste, newerPaste]); + + expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH, 5]); + expect(writes[1]).toBe("newer"); + expect(maximumActiveWrites).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalPaste.ts b/apps/mobile/src/features/terminal/terminalPaste.ts new file mode 100644 index 000000000000..3368cd3a5bb7 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPaste.ts @@ -0,0 +1,60 @@ +import { chunkTerminalWrite, encodeTerminalPaste } from "./terminalInput"; + +interface TerminalPasteInput { + readonly readText: () => Promise; + readonly write: (data: string) => Promise; + readonly onReadError: (cause: unknown) => void; +} + +export interface TerminalPasteSession { + readonly reset: (active: boolean) => void; + readonly paste: (input: TerminalPasteInput) => Promise; +} + +/** Coordinates clipboard reads and writes for the currently attached pty. */ +export function createTerminalPasteSession(): TerminalPasteSession { + let liveTarget: object | null = null; + let latestRequest = 0; + let writeTail: Promise = Promise.resolve(); + + return { + reset(active) { + liveTarget = active ? {} : null; + }, + + async paste({ readText, write, onReadError }) { + const target = liveTarget; + if (target === null) { + return; + } + const request = ++latestRequest; + const isCurrent = () => liveTarget === target && latestRequest === request; + + let text: string; + try { + text = await readText(); + } catch (cause) { + onReadError(cause); + return; + } + + if (!isCurrent()) { + return; + } + + const writePaste = async () => { + for (const chunk of chunkTerminalWrite(encodeTerminalPaste(text))) { + if (!isCurrent() || !(await write(chunk))) { + return; + } + } + }; + const queuedWrite = writeTail.then(writePaste, writePaste); + writeTail = queuedWrite.then( + () => undefined, + () => undefined, + ); + await queuedWrite; + }, + }; +} diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 85c57592d118..2f3e3777a965 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -133,6 +133,44 @@ describe("terminal session reducers", () => { }); }); + it("does not advance the lifecycle for the initial attach snapshot", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + }); + + it("advances the lifecycle for a live started snapshot", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const started = applyTerminalAttachStreamEvent(initial, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + + it("advances the lifecycle when a running terminal restarts in place", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const restarted = applyTerminalAttachStreamEvent(snapshot, { + type: "restarted", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + it("reduces terminal metadata snapshots, upserts, and removals", () => { const initial = applyTerminalMetadataStreamEvent([], { type: "snapshot", diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index ee444e36db41..b4508d387046 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -15,6 +15,7 @@ export interface TerminalSessionState { readonly hasRunningSubprocess: boolean; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface TerminalBufferState { @@ -23,6 +24,7 @@ export interface TerminalBufferState { readonly error: string | null; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface KnownTerminalSessionTarget { @@ -50,6 +52,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ error: null, updatedAt: null, version: 0, + lifecycleVersion: 0, }); export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ @@ -60,6 +63,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze( hasRunningSubprocess: false, updatedAt: null, version: 0, + lifecycleVersion: 0, }); export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; @@ -98,6 +102,7 @@ export function terminalBufferStateFromSnapshot( error: null, updatedAt: snapshot.updatedAt, version: 1, + lifecycleVersion: 0, }; } @@ -119,6 +124,7 @@ export function combineTerminalSessionState( hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), version: buffer.version, + lifecycleVersion: buffer.lifecycleVersion, }; } @@ -129,8 +135,16 @@ export function applyTerminalAttachStreamEvent( ): TerminalBufferState { switch (event.type) { case "snapshot": + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), + lifecycleVersion: + current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, + }; case "restarted": - return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), + lifecycleVersion: current.lifecycleVersion + 1, + }; case "output": return { ...current, From f0347322441f3b8e473a8d13ea7006cbcb4fb761 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 05:30:51 -0700 Subject: [PATCH 13/18] feat(web): show which sidebar threads hold an unsent draft (#9658) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/Sidebar.tsx | 76 +++++++++++++++++++++---- apps/web/src/composerDraftStore.test.ts | 26 +++++++++ apps/web/src/composerDraftStore.ts | 11 ++++ docs/user/thread-sidebar.md | 4 ++ 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d0ededf6ed83..03672d155a37 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -203,6 +203,7 @@ import { composerDraftHasUserContent, DraftId, useComposerDraftStore, + useThreadHasUnsentDraft, type ComposerThreadDraftState, type DraftSessionState, } from "../composerDraftStore"; @@ -487,6 +488,11 @@ function SortablePinnedThreadRow(props: { return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } +// Unsent work shares one look: the new-thread draft rows and thread rows +// with unsent composer text both use this tint and pen so they read alike. +const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]"; +const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80"; + // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it — @@ -552,19 +558,14 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { data-testid="sidebar-draft-row" className={cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none", - props.isActive - ? "bg-sidebar-row-active" - : "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]", + props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, )} onClick={handleActivate} onKeyDown={handleKeyDown} >
- + store.clearComposerContent); + const handleDiscardDraftClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + releaseComposerDraftUploads(threadRef); + clearComposerContent(threadRef); + }, + [clearComposerContent, threadRef], + ); const gitCwd = thread.worktreePath ?? props.projectCwd; const linkedPullRequestStatus = useLinkedThreadPullRequest( @@ -1163,9 +1177,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + : hasUnsentDraft + ? cn(draftSurfaceClassName, "text-sidebar-foreground") + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", isInFlight && !props.isActive && !isSelected && @@ -1251,6 +1267,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + // Same pen the new-thread draft rows lead with, so both kinds of unsent + // work read the same way in the list. + const draftIndicator = hasUnsentDraft ? ( + + + } + > + + + Unsent draft + + ) : null; const pinIndicator = props.isPinned ? ( props.pinningSupported ? ( @@ -1318,6 +1353,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className="size-4" /> + {draftIndicator} {title} {pinIndicator} {terminalStatusIcon} @@ -1465,6 +1501,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { >
+ {draftIndicator} - {props.settlementSupported || showSnoozeButton ? ( + {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( + {hasUnsentDraft ? ( + + + } + > + + + Discard draft + + ) : null} {showSnoozeButton ? ( { }); }); +describe("composerDraftStore unsent draft marker", () => { + const threadId = ThreadId.make("thread-unsent-marker"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("reports content for typed text and clears when the composer is emptied", () => { + const hasDraft = () => + composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(threadRef)); + + expect(hasDraft()).toBe(false); + + useComposerDraftStore.getState().setPrompt(threadRef, " "); + expect(hasDraft()).toBe(false); + + useComposerDraftStore.getState().setPrompt(threadRef, "follow up on the relay case"); + expect(hasDraft()).toBe(true); + + useComposerDraftStore.getState().clearComposerContent(threadRef); + expect(hasDraft()).toBe(false); + }); +}); + describe("composerDraftStore file attachments", () => { const threadId = ThreadId.make("thread-files"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2e8b8ae76779..4420f61837b2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4009,6 +4009,17 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose }); } +/** + * True when a real thread's composer holds unsent user content. Selects a + * boolean so the sidebar row that reads it re-renders only when the draft + * appears or disappears, not on every keystroke. + */ +export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean { + return useComposerDraftStore((state) => + composerDraftHasUserContent(getComposerDraftState(state, threadRef)), + ); +} + export function useComposerDraftModelState( threadRef: ComposerThreadTarget, ): ComposerDraftModelState { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 204678eb1d2f..3d6311641d59 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -36,6 +36,10 @@ by older clients on one device no longer control this behavior. When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. +A thread whose composer holds unsent text or attachments shows an amber tint and a pen icon in the +sidebar, the same marks a new-thread draft uses. On web and desktop, hover the row and choose the +**X** to discard that draft without opening the thread. + Right-click a pull request link in a thread and choose **Link to thread** to show that pull request in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. From 01f3e50eca5102ccd881de6f942a98fe6a518ad4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 05:41:32 -0700 Subject: [PATCH 14/18] fix(server): unblock OpenCode approvals and stop (#9653) OpenCode could show an Approval badge with no controls, appear stuck on TodoWrite, and keep showing a running turn after Stop. - Show every permission, including old saved requests. Keep failed replies retryable and close completed requests even when reply events are lost. - Keep OpenCode output pipes drained and automatic replies out of the event loop. Handle disconnects, reconnects, and confirmed stops without stale requests or running states. - Show native task progress and command results. Do not treat TodoWrite or approval history as file edits or executed commands. - Ignore late aborts and task updates after a turn finishes. Fixes #4795 Fixes #7113 Fixes #5760 Created with GPT-6 Astra (preview) in Codex. Reviewed and merged with Claude Fable 5.1 in Claude Code. --- apps/mobile/src/lib/threadActivity.test.ts | 62 ++ apps/mobile/src/lib/threadActivity.ts | 10 +- .../Layers/ProjectionPipeline.test.ts | 82 +- .../Layers/ProjectionPipeline.ts | 38 + .../Layers/ProviderRuntimeIngestion.test.ts | 190 ++++ .../Layers/ProviderRuntimeIngestion.ts | 28 +- .../provider/Layers/OpenCodeAdapter.test.ts | 886 ++++++++++++++++-- .../src/provider/Layers/OpenCodeAdapter.ts | 480 ++++++++-- .../opencodeRuntime.environment.test.ts | 87 ++ .../opencodeRuntime.inventory.test.ts | 42 +- .../opencodeRuntime.permissions.test.ts | 56 +- apps/server/src/provider/opencodeRuntime.ts | 69 +- apps/web/src/session-logic.test.ts | 35 +- apps/web/src/session-logic.ts | 10 +- docs/user/providers-opencode.md | 37 +- .../src/work-log/presentation.test.ts | 34 + .../src/work-log/presentation.ts | 7 + 17 files changed, 1935 insertions(+), 218 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index cca0bf6890f5..29d59dae687b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -263,6 +263,68 @@ describe("pending user input answers", () => { }); describe("pending approvals", () => { + it.each([{}, { requestType: "unknown" }])( + "exposes legacy OpenCode approvals without a known request kind: %j", + (legacyPayload) => { + const requested = makeActivity({ + id: EventId.make("approval-legacy"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, + }); + + expect(derivePendingApprovals([requested])).toEqual([ + { + requestId: "per-legacy", + requestKind: "command", + createdAt: requested.createdAt, + detail: "*", + }, + ]); + }, + ); + + it.each(["tool_user_input", "auth_tokens_refresh"])( + "does not turn %s into an approval", + (requestType) => { + const activity = makeActivity({ + id: EventId.make("approval-non-approval"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "not-an-approval", requestType }, + }); + + expect(derivePendingApprovals([activity])).toEqual([]); + }, + ); + + it.each(["approval.resolved", "provider.approval.respond.failed"])( + "removes legacy approvals after %s", + (kind) => { + const requested = makeActivity({ + id: EventId.make("approval-legacy-open"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "per-legacy", requestType: "unknown" }, + }); + const resolved = makeActivity({ + id: EventId.make("approval-legacy-resolved"), + kind, + summary: "Approval resolved", + createdAt: "2026-08-24T00:00:01.000Z", + payload: { + requestId: "per-legacy", + detail: "Unknown pending permission request: per-legacy", + }, + }); + + expect(derivePendingApprovals([requested, resolved])).toEqual([]); + }, + ); + it("keeps app access approvals and persistence choices from remote environments", () => { const options = [ { decision: "decline", label: "Decline" }, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a034330006c4..88e957eab44a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1754,10 +1754,16 @@ export function derivePendingApprovals( ? payload.options.filter(isProviderApprovalOption) : undefined; - if (activity.kind === "approval.requested" && requestId && requestKind) { + if ( + activity.kind === "approval.requested" && + requestId && + payload?.requestType !== "tool_user_input" && + payload?.requestType !== "auth_tokens_refresh" + ) { openByRequestId.set(requestId, { requestId, - requestKind, + // Older OpenCode requests can have no recognized approval kind. + requestKind: requestKind ?? "command", createdAt: activity.createdAt, ...(detail ? { detail } : {}), ...(appName ? { appName } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1b8a451175f3..9e4f88a5be10 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1,4 +1,5 @@ import { + ApprovalRequestId, CheckpointRef, CommandId, CorrelationId, @@ -2744,7 +2745,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("ignores non-stale provider approval response failures", () => + it.effect("restores pending approvals when a provider reply fails", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2829,6 +2830,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + yield* appendAndProject({ + type: "thread.approval-response-requested", + eventId: EventId.make("evt-nonstale-approval-response"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.500Z", + commandId: CommandId.make("cmd-nonstale-approval-response"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-response"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + requestId: ApprovalRequestId.make("approval-request-nonstale-existing"), + decision: "accept", + createdAt: "2026-02-26T12:45:02.500Z", + }, + }); + yield* appendAndProject({ type: "thread.activity-appended", eventId: EventId.make("evt-nonstale-approval-4"), @@ -2921,6 +2940,67 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { WHERE thread_id = 'thread-nonstale-approval' `; assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-resolved"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:05.000Z", + commandId: CommandId.make("cmd-nonstale-approval-resolved"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-resolved"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-resolved"), + tone: "approval", + kind: "approval.resolved", + summary: "Approval resolved", + payload: { + requestId: "approval-request-nonstale-existing", + decision: "accept", + }, + turnId: null, + createdAt: "2026-02-26T12:45:05.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-late-failure"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:06.000Z", + commandId: CommandId.make("cmd-nonstale-approval-late-failure"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-late-failure"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-late-failure"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-existing", + detail: "Provider timed out while responding to approval request", + }, + turnId: null, + createdAt: "2026-02-26T12:45:06.000Z", + }, + }, + }); + + const resolvedThreadRows = yield* sql<{ readonly pendingApprovalCount: number }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-nonstale-approval' + `; + assert.deepEqual(resolvedThreadRows, [{ pendingApprovalCount: 0 }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1ba1b6afa4f3..ee07f9fb4cdc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1728,6 +1728,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; } + if (Option.isNone(existingRow) || existingRow.value.status !== "resolved") { + return; + } + + // Sending a reply clears the badge before the provider accepts it. + // A failed reply must restore the request unless a terminal event + // already closed it, including a reply from another client. + const requestActivities = (yield* projectionThreadActivityRepository.listByThreadId({ + threadId: existingRow.value.threadId, + })).filter((activity) => extractActivityRequestId(activity.payload) === requestId); + const wasRequested = requestActivities.some( + (activity) => activity.kind === "approval.requested", + ); + const wasResolved = requestActivities.some((activity) => { + if (activity.kind === "approval.resolved") { + return true; + } + if (activity.kind !== "provider.approval.respond.failed") { + return false; + } + const activityPayload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + return isStalePendingApprovalFailureDetail( + typeof activityPayload?.detail === "string" + ? activityPayload.detail.toLowerCase() + : null, + ); + }); + if (wasRequested && !wasResolved) { + yield* projectionPendingApprovalRepository.upsert({ + ...existingRow.value, + status: "pending", + decision: null, + resolvedAt: null, + }); + } return; } // Only approval-requested activities should create pending-approval diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index ef9300a196ad..828d48508638 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -371,6 +371,196 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it.each([ + { delivery: "buffered", enableLegacyTokenStreaming: false }, + { delivery: "streamed", enableLegacyTokenStreaming: true }, + ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { + const harness = await createHarness({ + serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming }, + }); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("opencode-aborted-turn"); + const base = { + provider: ProviderDriverKind.make("opencode"), + threadId, + turnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + harness.emit({ ...base, type: "turn.started", eventId: asEventId("opencode-started") }); + harness.emit({ + ...base, + type: "content.delta", + eventId: asEventId("opencode-partial-text"), + itemId: asItemId("opencode-text-part"), + payload: { streamKind: "assistant_text", delta: "Work before the stop." }, + }); + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId("opencode-aborted"), + createdAt: "2026-01-01T00:00:02.000Z", + payload: { reason: "Interrupted by user." }, + }); + + await harness.drain(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ + status: "interrupted", + activeTurnId: null, + lastError: null, + }); + expect(thread?.latestTurn).toMatchObject({ + turnId, + state: "interrupted", + completedAt: "2026-01-01T00:00:02.000Z", + }); + expect(thread?.messages).toEqual([ + expect.objectContaining({ + role: "assistant", + turnId, + text: "Work before the stop.", + streaming: false, + }), + ]); + }); + + it.each([ + { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, + { source: "an unspecified turn", turnId: undefined }, + ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { + const harness = await createHarness({ + serverSettings: { enableLegacyTokenStreaming: true }, + }); + const threadId = asThreadId("thread-1"); + const stoppedTurnId = asTurnId("opencode-stopped-turn"); + const nextTurnId = asTurnId("opencode-next-turn"); + const base = { + provider: ProviderDriverKind.make("opencode"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + harness.emit({ + ...base, + type: "turn.started", + eventId: asEventId("opencode-first-started"), + turnId: stoppedTurnId, + }); + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId("opencode-first-aborted"), + turnId: stoppedTurnId, + payload: { reason: "Interrupted by user." }, + }); + harness.emit({ + ...base, + type: "turn.started", + eventId: asEventId("opencode-next-started"), + turnId: nextTurnId, + }); + harness.emit({ + ...base, + type: "content.delta", + eventId: asEventId("opencode-next-partial-text"), + turnId: nextTurnId, + itemId: asItemId("opencode-next-text-part"), + payload: { streamKind: "assistant_text", delta: "The next turn is running." }, + }); + await harness.drain(); + + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId("opencode-late-abort"), + ...(lateAbort.turnId ? { turnId: lateAbort.turnId } : {}), + payload: { reason: "Interrupted by user." }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ status: "running", activeTurnId: nextTurnId }); + expect(thread?.latestTurn).toMatchObject({ turnId: nextTurnId, state: "running" }); + expect(thread?.messages).toEqual([ + expect.objectContaining({ + turnId: nextTurnId, + text: "The next turn is running.", + streaming: true, + }), + ]); + + harness.emit({ + ...base, + type: "turn.completed", + eventId: asEventId("opencode-next-completed"), + turnId: nextTurnId, + createdAt: "2026-01-01T00:00:02.000Z", + payload: { state: "completed" }, + }); + await harness.drain(); + + const pendingAt = "2026-01-01T00:00:03.000Z"; + for (const hasPendingStart of [false, true]) { + if (hasPendingStart) { + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("opencode-pending-start"), + threadId, + message: { + messageId: asMessageId("opencode-pending-message"), + role: "user", + text: "Start another turn.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: pendingAt, + }); + harness.emit({ + ...base, + type: "session.state.changed", + eventId: asEventId("opencode-pending-starting"), + createdAt: pendingAt, + payload: { state: "starting" }, + }); + } + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId(`opencode-late-abort-after-completion-${hasPendingStart}`), + ...(lateAbort.turnId ? { turnId: lateAbort.turnId } : {}), + createdAt: "2026-01-01T00:00:04.000Z", + payload: { reason: "Interrupted by user." }, + }); + await harness.drain(); + + const completedThread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + expect(completedThread?.session).toMatchObject({ + status: hasPendingStart ? "starting" : "ready", + activeTurnId: null, + }); + expect(completedThread?.latestTurn).toMatchObject({ turnId: nextTurnId, state: "completed" }); + } + + harness.emit({ + ...base, + type: "turn.started", + eventId: asEventId("opencode-pending-started"), + turnId: asTurnId("opencode-pending-turn"), + createdAt: "2026-01-01T00:00:05.000Z", + }); + await harness.drain(); + const startedThread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + expect(startedThread?.latestTurn).toMatchObject({ + turnId: asTurnId("opencode-pending-turn"), + state: "running", + requestedAt: pendingAt, + }); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index f78088675045..257c67b18eb5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1578,6 +1578,7 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const isTerminalTurn = event.type === "turn.completed" || event.type === "turn.aborted"; const isCompactedThreadState = event.type === "thread.state.changed" && event.payload.state === "compacted"; const pendingTurnStart = @@ -1586,7 +1587,7 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" || + isTerminalTurn || isCompactedThreadState ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ threadId: thread.id, @@ -1624,6 +1625,7 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -1631,14 +1633,10 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // No active turn tracked: accept only completions that name their - // turn (covers a real completion whose turn.started was lost). An - // untargeted completion cannot prove it belongs to any turn this - // thread ran — the known emitter was the Claude resume handshake - // (system/init + result(num_turns: 0)), which is not a turn at - // all — and applying it here stomps the "starting" lifecycle - // state while a turn start is pending. - return eventTurnId !== undefined; + // A named completion can recover a lost turn.started event. + // An abort needs an active turn so a delayed stop cannot replace + // a ready session or clear a newer pending start. + return event.type === "turn.completed" && eventTurnId !== undefined; default: return true; } @@ -1654,7 +1652,7 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + isTerminalTurn ) { const status = (() => { switch (event.type) { @@ -1666,6 +1664,8 @@ const make = Effect.gen(function* () { return "running"; case "session.exited": return "stopped"; + case "turn.aborted": + return "interrupted"; case "turn.completed": return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" @@ -1680,7 +1680,7 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : isTerminalTurn || event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( @@ -1694,7 +1694,7 @@ const make = Effect.gen(function* () { : event.type === "turn.completed" && normalizeRuntimeTurnState(event.payload.state) === "failed" ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" + : status === "ready" || status === "interrupted" ? null : (thread.session?.lastError ?? null); @@ -1922,7 +1922,7 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if (isTerminalTurn) { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; @@ -2055,7 +2055,7 @@ const make = Effect.gen(function* () { } else if (!conflictsWithActiveTurn) { if (event.type === "turn.plan.updated") { threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); - } else if (event.type === "turn.completed" || event.type === "turn.aborted") { + } else if (isTerminalTurn && shouldApplyThreadLifecycle) { threadPlanProgress.clearThreadPlanProgress(thread.id); } } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index dfa6f20be7c0..261726de2146 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -15,7 +15,11 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; +import type { + Event as OpenCodeEvent, + PermissionRequest, + QuestionRequest, +} from "@opencode-ai/sdk/v2"; import { ApprovalRequestId, @@ -85,23 +89,30 @@ const runtimeMock = { promptAsyncImplementation: null as (() => Promise) | null, autoPromptEcho: true, autoConnect: true, + endEventStream: false, promptEchoEvents: [] as Array, closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, + eventStreamError: null as ((cause: unknown) => void) | null, permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, - permissionReplyImplementation: null as (() => Promise) | null, + permissionReplyImplementation: null as ((signal?: AbortSignal) => Promise) | null, + permissionReplySignals: [] as AbortSignal[], questionReplyCalls: [] as Array<{ requestID: string; answers: ReadonlyArray>; }>, + questionReplyImplementation: null as ((signal?: AbortSignal) => Promise) | null, sessionStatus: "idle" as "idle" | "busy", sessionStatusFailures: 0, sessionStatusCalls: 0, sessionStatusImplementation: null as (() => Promise) | null, sessionGetIds: [] as string[], sessionGetObserved: null as ((sessionID: string) => void) | null, + sessionGetImplementation: null as + | ((sessionID: string, signal?: AbortSignal) => Promise) + | null, missingSessionIds: new Set(), transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), @@ -137,20 +148,25 @@ const runtimeMock = { this.state.promptAsyncImplementation = null; this.state.autoPromptEcho = true; this.state.autoConnect = true; + this.state.endEventStream = false; this.state.promptEchoEvents.length = 0; this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; + this.state.eventStreamError = null; this.state.permissionReplyCalls.length = 0; this.state.permissionReplyImplementation = null; + this.state.permissionReplySignals.length = 0; this.state.questionReplyCalls.length = 0; + this.state.questionReplyImplementation = null; this.state.sessionStatus = "idle"; this.state.sessionStatusFailures = 0; this.state.sessionStatusCalls = 0; this.state.sessionStatusImplementation = null; this.state.sessionGetIds.length = 0; this.state.sessionGetObserved = null; + this.state.sessionGetImplementation = null; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); @@ -222,9 +238,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` }, }; }, - get: async ({ sessionID }: { sessionID: string }) => { + get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { runtimeMock.state.sessionGetIds.push(sessionID); runtimeMock.state.sessionGetObserved?.(sessionID); + if (runtimeMock.state.sessionGetImplementation) { + await runtimeMock.state.sessionGetImplementation(sessionID, options?.signal); + } // The real client is `throwOnError: true`: non-2xx rejects rather // than resolving, so missing → 404 throw, transient → 500 throw. if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { @@ -264,6 +283,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.abortSignals.push(options.signal); } await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); + runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter( + (request) => request.sessionID !== sessionID, + ); + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.sessionID !== sessionID, + ); }, children: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.sessionChildrenCalls.push(sessionID); @@ -357,19 +382,60 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, }, event: { - subscribe: async () => { + subscribe: async ( + _input: unknown, + options?: { signal?: AbortSignal; onSseError?: (cause: unknown) => void }, + ) => { runtimeMock.state.eventSubscribeObserved?.(); + runtimeMock.state.eventStreamError = options?.onSseError ?? null; return { stream: (async function* () { - if (runtimeMock.state.autoConnect) { - yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; - } - for (const event of runtimeMock.state.subscribedEvents) { - const resolved = await event; - while (runtimeMock.state.promptEchoEvents.length > 0) { - yield runtimeMock.state.promptEchoEvents.shift(); + const aborted = promiseWithResolvers(); + const onAbort = () => aborted.resolve(undefined); + options?.signal?.addEventListener("abort", onAbort, { once: true }); + try { + if (runtimeMock.state.autoConnect) { + yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; + } + for (const event of runtimeMock.state.subscribedEvents) { + if (options?.signal?.aborted) return; + const resolved = await Promise.race([event, aborted.promise]); + if (options?.signal?.aborted) return; + while (runtimeMock.state.promptEchoEvents.length > 0) { + yield runtimeMock.state.promptEchoEvents.shift(); + } + const nativeEvent = resolved as OpenCodeEvent; + if (nativeEvent.type === "permission.asked") { + runtimeMock.state.pendingPermissions = + runtimeMock.state.pendingPermissions.filter( + (request) => request.id !== nativeEvent.properties.id, + ); + runtimeMock.state.pendingPermissions.push(nativeEvent.properties); + } else if (nativeEvent.type === "permission.replied") { + runtimeMock.state.pendingPermissions = + runtimeMock.state.pendingPermissions.filter( + (request) => request.id !== nativeEvent.properties.requestID, + ); + } else if (nativeEvent.type === "question.asked") { + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.id !== nativeEvent.properties.id, + ); + runtimeMock.state.pendingQuestions.push(nativeEvent.properties); + } else if ( + nativeEvent.type === "question.replied" || + nativeEvent.type === "question.rejected" + ) { + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.id !== nativeEvent.properties.requestID, + ); + } + yield resolved; } - yield resolved; + if (!runtimeMock.state.endEventStream && !options?.signal?.aborted) { + await aborted.promise; + } + } finally { + options?.signal?.removeEventListener("abort", onAbort); } })(), }; @@ -384,11 +450,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.pendingPermissions, }; }, - reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { + reply: async ( + { requestID, reply }: { requestID: string; reply: string }, + options?: { signal?: AbortSignal }, + ) => { runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + if (options?.signal) runtimeMock.state.permissionReplySignals.push(options.signal); if (runtimeMock.state.permissionReplyImplementation) { - await runtimeMock.state.permissionReplyImplementation(); + await runtimeMock.state.permissionReplyImplementation(options?.signal); } + runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter( + (request) => request.id !== requestID, + ); }, }, question: { @@ -400,14 +473,21 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.pendingQuestions, }; }, - reply: async ({ - requestID, - answers, - }: { - requestID: string; - answers: ReadonlyArray>; - }) => { + reply: async ( + { + requestID, + answers, + }: { + requestID: string; + answers: ReadonlyArray>; + }, + options?: { signal?: AbortSignal }, + ) => { runtimeMock.state.questionReplyCalls.push({ requestID, answers }); + await runtimeMock.state.questionReplyImplementation?.(options?.signal); + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.id !== requestID, + ); }, }, }) as unknown as ReturnType, @@ -2576,6 +2656,411 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect.each([ + { permission: "external_directory", decision: "accept", reply: "once" }, + { permission: "doom_loop", decision: "acceptForSession", reply: "always" }, + { permission: "todowrite", decision: "decline", reply: "reject" }, + { permission: "webfetch", decision: "cancel", reply: "reject" }, + { permission: "custom_tool", decision: "accept", reply: "once" }, + ] as const)( + "shows $permission approval and resolves its $decision reply without SSE", + ({ permission, decision, reply }) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-permission-${permission}`); + const request = { + ...permissionRequest(`per_${permission}`, "http://127.0.0.1:9999/session"), + permission, + patterns: ["*"], + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-permission", + type: "permission.asked", + properties: request, + } satisfies OpenCodeEvent, + ]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const opened = Option.getOrThrow(yield* Fiber.join(openedFiber)); + NodeAssert.ok(opened.type === "request.opened"); + NodeAssert.equal(opened.payload.requestType, "command_execution_approval"); + NodeAssert.equal(opened.payload.detail, permission.replaceAll("_", " ")); + NodeAssert.deepEqual( + opened.payload.options?.map((option) => option.label), + ["Allow once", "Allow for workspace", "Deny"], + ); + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "request.resolved", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), decision); + const resolved = Option.getOrThrow(yield* Fiber.join(resolvedFiber)); + NodeAssert.equal(resolved.requestId, request.id); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), decision); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a permission reply retryable after its HTTP request times out", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-permission-timeout"); + const request = permissionRequest("per_timeout", "http://127.0.0.1:9999/session"); + const replyStarted = promiseWithResolvers(); + runtimeMock.state.permissionReplyImplementation = async () => { + replyStarted.resolve(undefined); + await new Promise(() => {}); + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-ask", type: "permission.asked", properties: request }, + ]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(openedFiber); + const replyFiber = yield* adapter + .respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept") + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => replyStarted.promise); + yield* Effect.yieldNow; + yield* advanceTestClock(10_000); + NodeAssert.equal(Exit.isFailure(yield* Fiber.join(replyFiber)), true); + NodeAssert.equal(runtimeMock.state.permissionReplySignals[0]?.aborted, true); + runtimeMock.state.permissionReplyImplementation = null; + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.resolved"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"); + NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(resolvedFiber)).requestId, request.id); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a recovering permission retryable until its native request is loaded", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-permission-recovering"); + const request = permissionRequest("per_recovering", "ses_resumed"); + const listStarted = promiseWithResolvers(); + const releaseList = promiseWithResolvers(); + runtimeMock.state.permissionListImplementation = async () => { + listStarted.resolve(undefined); + return await releaseList.promise; + }; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: request.sessionID }, + }); + yield* Effect.promise(() => listStarted.promise); + const reply = yield* adapter + .respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept") + .pipe(Effect.result); + NodeAssert.equal(reply._tag, "Failure"); + if (reply._tag === "Failure" && reply.failure._tag === "ProviderAdapterRequestError") { + NodeAssert.match(reply.failure.detail, /still loading/); + } + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + releaseList.resolve([request]); + yield* Fiber.join(openedFiber); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply: "once" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes missing permissions and questions after reconnect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-missing-requests"); + const sessionID = "http://127.0.0.1:9999/session"; + const reconnect = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-permission", + type: "permission.asked", + properties: permissionRequest("per_missing", sessionID), + }, + { + id: "evt-question", + type: "question.asked", + properties: questionRequest("que_missing", sessionID), + }, + reconnect.promise, + ]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "user-input.requested"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(openedFiber); + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.resolved" || event.type === "user-input.resolved"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + runtimeMock.state.pendingPermissions = []; + runtimeMock.state.pendingQuestions = []; + reconnect.resolve({ id: "evt-reconnected", type: "server.connected", properties: {} }); + const resolved = yield* Fiber.join(resolvedFiber); + NodeAssert.deepEqual( + resolved.map((event) => event.requestId), + ["per_missing", "que_missing"], + ); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []); + NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, []); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes pending requests after Stop and ignores late requests from that turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stop-requests"); + const sessionID = "http://127.0.0.1:9999/session"; + const startRequests = promiseWithResolvers(); + const lateRequests = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + startRequests.promise, + { + id: "evt-question", + type: "question.asked", + properties: questionRequest("que_stop", sessionID), + }, + lateRequests.promise, + { + id: "evt-late-question", + type: "question.asked", + properties: questionRequest("que_late", sessionID), + }, + { id: "evt-drained", type: "session.compacted", properties: { sessionID } }, + ]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "user-input.requested"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + startRequests.resolve({ + id: "evt-permission", + type: "permission.asked", + properties: permissionRequest("per_stop", sessionID), + }); + yield* Fiber.join(openedFiber); + const stoppedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.aborted"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.interruptTurn(threadId, turn.turnId); + const stopped = yield* Fiber.join(stoppedFiber); + NodeAssert.deepEqual( + stopped.map((event) => event.type), + ["request.resolved", "user-input.resolved", "turn.aborted"], + ); + const lateFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + lateRequests.resolve({ + id: "evt-late-permission", + type: "permission.asked", + properties: permissionRequest("per_late", sessionID), + }); + const late = yield* Fiber.join(lateFiber); + NodeAssert.deepEqual( + late.map((event) => event.type), + ["thread.state.changed"], + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps progress live during automatic approval and never reopens a finished turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-auto-approval-progress"); + const sessionID = "http://127.0.0.1:9999/session"; + const ask = promiseWithResolvers(); + const idle = promiseWithResolvers(); + const replyStarted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + runtimeMock.state.permissionReplyImplementation = async () => { + replyStarted.resolve(undefined); + await releaseReply.promise; + throw new Error("reply response lost"); + }; + runtimeMock.state.subscribedEvents = [ask.promise, idle.promise]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + ask.resolve({ + id: "evt-ask", + type: "permission.asked", + properties: permissionRequest("per_slow_auto", sessionID), + }); + yield* Effect.promise(() => replyStarted.promise); + idle.resolve({ + id: "evt-idle", + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + const completed = yield* Fiber.join(completedFiber); + NodeAssert.equal( + completed.some((event) => event.type === "request.opened"), + false, + ); + const remainingFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + releaseReply.resolve(undefined); + yield* advanceTestClock(10_000); + yield* adapter.stopSession(threadId); + const remaining = yield* Fiber.join(remainingFiber); + NodeAssert.equal( + remaining.some((event) => event.type === "request.opened"), + false, + ); + }), + ); + + it.effect("keeps automatic approval fallback available after a steer", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-auto-approval-steer"); + const ask = promiseWithResolvers(); + const replyStarted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + runtimeMock.state.sessionStatus = "busy"; + runtimeMock.state.permissionReplyImplementation = async () => { + replyStarted.resolve(undefined); + await releaseReply.promise; + throw new Error("reply failed"); + }; + runtimeMock.state.subscribedEvents = [ask.promise]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const modelSelection = createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ); + const turn = yield* adapter.sendTurn({ threadId, input: "Work", modelSelection }); + ask.resolve({ + id: "evt-ask", + type: "permission.asked", + properties: permissionRequest("per_steer_auto", "http://127.0.0.1:9999/session"), + }); + yield* Effect.promise(() => replyStarted.promise); + const steered = yield* adapter.sendTurn({ + threadId, + input: "Keep the change small", + modelSelection, + }); + NodeAssert.equal(steered.turnId, turn.turnId); + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + releaseReply.resolve(undefined); + NodeAssert.equal( + Option.getOrThrow(yield* Fiber.join(openedFiber)).requestId, + "per_steer_auto", + ); + runtimeMock.state.permissionReplyImplementation = null; + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_steer_auto"), "accept"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("routes child-session approval requests and replies through the parent thread", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -2691,6 +3176,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId(`thread-full-access-${requestId}`); + const replyStarted = promiseWithResolvers(); + runtimeMock.state.permissionReplyImplementation = async () => + replyStarted.resolve(undefined); runtimeMock.state.subscribedEvents = [ { id: "evt-child-created", @@ -2709,11 +3197,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { type: "permission.asked", properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, }, - { + replyStarted.promise.then(() => ({ id: "evt-permission-replied", type: "permission.replied", properties: { sessionID, requestID: requestId, reply: "once" }, - }, + })), // The suppressed ask emits nothing, so an empty question serves as a // sentinel that closes the collected stream once the pump is past it. { @@ -3000,53 +3488,68 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("retries ancestry for one live child request after a transient failure", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-child-request-ancestry-retry"); - const parentId = "http://127.0.0.1:9999/session"; - const ancestryAttempted = promiseWithResolvers(); - runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); - runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); - runtimeMock.state.sessionGetObserved = (sessionID) => { - if (sessionID === "ses_existing_child") { - ancestryAttempted.resolve(undefined); + it.effect.each(["failure", "timeout"] as const)( + "retries ancestry for a child request after a transient %s", + (lookupFailure) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-child-request-ancestry-retry-${lookupFailure}`); + const parentId = "http://127.0.0.1:9999/session"; + const ancestryAttempted = promiseWithResolvers(); + runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); + runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); + let lookupSignal: AbortSignal | undefined; + if (lookupFailure === "timeout") { + runtimeMock.state.sessionGetImplementation = async (_sessionID, signal) => { + lookupSignal = signal; + await new Promise(() => {}); + }; } - }; - runtimeMock.state.subscribedEvents = [ - { - id: "evt-existing-child-permission", - type: "permission.asked", - properties: permissionRequest("per_retry", "ses_existing_child"), - }, - ]; + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === "ses_existing_child") { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-existing-child-permission", + type: "permission.asked", + properties: permissionRequest("per_retry", "ses_existing_child"), + }, + ]; - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter( - (event) => - event.threadId === threadId && - (event.type === "runtime.warning" || event.type === "request.opened"), - ), - Stream.take(2), - Stream.runCollect, - Effect.forkChild, - ); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "approval-required", - }); - yield* Effect.promise(() => ancestryAttempted.promise); - runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); - yield* advanceTestClock(250); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "runtime.warning" || event.type === "request.opened"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + if (lookupFailure === "timeout") { + yield* Effect.yieldNow; + yield* advanceTestClock(10_000); + NodeAssert.equal(lookupSignal?.aborted, true); + runtimeMock.state.sessionGetImplementation = null; + } + runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); + yield* advanceTestClock(250); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - NodeAssert.deepEqual( - events.map((event) => event.type), - ["runtime.warning", "request.opened"], - ); - yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); - }), + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["runtime.warning", "request.opened"], + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); + }), ); it.effect("does not resurrect a recovered child request after its live reply", () => @@ -3101,7 +3604,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const response = yield* Effect.exit( adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"), ); - NodeAssert.equal(Exit.isFailure(response), true); + NodeAssert.equal(Exit.isSuccess(response), true); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []); }), ); @@ -3153,7 +3657,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const response = yield* Effect.exit( adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"), ); - NodeAssert.equal(Exit.isFailure(response), true); + NodeAssert.equal(Exit.isSuccess(response), true); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []); }), ); @@ -5286,6 +5791,249 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("maps native task progress only while a turn is active", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-native-progress"); + const sessionID = "http://127.0.0.1:9999/session"; + const startProgress = promiseWithResolvers(); + const finishTurn = promiseWithResolvers(); + const lateProgress = promiseWithResolvers(); + const todos = [ + { content: "Read files", status: "completed", priority: "high" }, + { content: "Fix OpenCode", status: "in_progress", priority: "high" }, + { content: "Run tests", status: "pending", priority: "medium" }, + { content: "Old task", status: "cancelled", priority: "low" }, + ]; + const todoEvent = { + id: "evt-todos", + type: "todo.updated", + properties: { sessionID, todos }, + } satisfies OpenCodeEvent; + runtimeMock.state.subscribedEvents = [ + startProgress.promise, + ...["todowrite", "bash"].map( + (tool) => + ({ + id: `evt-${tool}`, + type: "message.part.updated", + properties: { + sessionID, + time: 2, + part: { + id: `part-${tool}`, + sessionID, + messageID: "msg-tools", + type: "tool", + callID: `call-${tool}`, + tool, + state: { + status: "completed", + input: tool === "bash" ? { command: "pwd" } : { todos }, + output: tool === "bash" ? "/repo\n" : "Tasks updated", + title: tool === "bash" ? "Working directory" : "Tasks updated", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + }, + }) satisfies OpenCodeEvent, + ), + finishTurn.promise, + lateProgress.promise, + { id: "evt-progress-drained", type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.plan.updated" || event.type === "item.completed"), + ), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work through the task list", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + startProgress.resolve(todoEvent); + const events = yield* Fiber.join(eventsFiber); + const plan = events.find((event) => event.type === "turn.plan.updated"); + NodeAssert.equal(plan?.turnId, turn.turnId); + NodeAssert.deepEqual(plan?.payload.plan, [ + { step: "Read files", status: "completed" }, + { step: "Fix OpenCode", status: "inProgress" }, + { step: "Run tests", status: "pending" }, + ]); + const tools = events.filter((event) => event.type === "item.completed"); + NodeAssert.equal(tools[0]?.payload.itemType, "dynamic_tool_call"); + NodeAssert.equal(tools[1]?.payload.title, "Working directory"); + NodeAssert.partialDeepStrictEqual(tools[1]?.payload.data, { + command: "pwd", + result: "/repo\n", + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + finishTurn.resolve({ + id: "evt-progress-completed", + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + yield* Fiber.join(completedFiber); + const lateEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + lateProgress.resolve({ ...todoEvent, id: "evt-late-todos" }); + NodeAssert.deepEqual( + (yield* Fiber.join(lateEventsFiber)).map((event) => event.type), + ["thread.state.changed"], + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("warns on disconnection and recovers a completion missed during reconnect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-reconnect-completion"); + const reconnect = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [reconnect.promise]; + runtimeMock.state.sessionStatus = "busy"; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const warningFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.eventStreamError?.(new Error("socket closed")); + const warning = Option.getOrThrow(yield* Fiber.join(warningFiber)); + NodeAssert.ok(warning.type === "runtime.warning"); + NodeAssert.equal(warning.payload.message, "OpenCode connection lost. Reconnecting."); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.sessionStatus = "idle"; + reconnect.resolve({ + id: "evt-reconnected", + type: "server.connected", + properties: {}, + } satisfies OpenCodeEvent); + NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(completedFiber)).turnId, turn.turnId); + NodeAssert.equal( + (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status, + "ready", + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "ends a running session on clean stream closure without discarding unresolved permissions", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stream-closed"); + const endStream = promiseWithResolvers(); + const request = permissionRequest("per_disconnect", "http://127.0.0.1:9999/session"); + runtimeMock.state.pendingPermissions = [request]; + runtimeMock.state.subscribedEvents = [endStream.promise]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(openedFiber); + yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const exitedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + runtimeMock.state.endEventStream = true; + runtimeMock.state.abortImplementation = async () => { + throw new Error("server unreachable"); + }; + endStream.resolve({ + id: "evt-busy", + type: "session.status", + properties: { sessionID: request.sessionID, status: { type: "busy" } }, + }); + const exited = yield* Fiber.join(exitedFiber); + NodeAssert.equal( + exited.some((event) => event.type === "request.resolved"), + false, + ); + NodeAssert.match( + exited.find((event) => event.type === "runtime.error")?.payload.message ?? "", + /event stream ended/, + ); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + runtimeMock.state.endEventStream = false; + runtimeMock.state.subscribedEvents = []; + runtimeMock.state.abortImplementation = null; + const recoveredFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: session.resumeCursor, + }); + NodeAssert.equal( + Option.getOrThrow(yield* Fiber.join(recoveredFiber)).requestId, + request.id, + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index e0305236a717..9cce1e6b889e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -417,6 +417,9 @@ type EventBaseInput = { function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); + if (normalized === "todowrite" || normalized === "todoread") { + return "dynamic_tool_call"; + } if (normalized.includes("bash") || normalized.includes("command")) { return "command_execution"; } @@ -449,16 +452,15 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { function mapPermissionToRequestType( permission: string, -): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { +): "command_execution_approval" | "file_read_approval" | "file_change_approval" { switch (permission) { - case "bash": - return "command_execution_approval"; case "read": return "file_read_approval"; case "edit": return "file_change_approval"; default: - return "unknown"; + // Every OpenCode permission needs an actionable approval in each client. + return "command_execution_approval"; } } @@ -1062,6 +1064,10 @@ export function makeOpenCodeAdapter( context.interruptedTurnId = undefined; context.awaitingBusyAfterInterruption = false; context.reconcileIdleStatus = false; + for (const requestId of context.autoRepliedRequestIds) { + context.emittedTerminalRequestIds.add(requestId); + } + context.autoRepliedRequestIds.clear(); applyProviderSessionUpdate( context, { status: "ready" }, @@ -1071,6 +1077,7 @@ export function makeOpenCodeAdapter( if (pendingIdleReconciliation?.fiber) { yield* Fiber.interrupt(pendingIdleReconciliation.fiber); } + yield* schedulePendingRequestRecovery(context); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1425,6 +1432,7 @@ export function makeOpenCodeAdapter( { clearActiveTurnId: true, clearLastError: true }, ); } + yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1578,7 +1586,19 @@ export function makeOpenCodeAdapter( const seen = new Set(); const getSession = (sessionID: string) => - runOpenCodeSdk("session.get", () => context.client.session.get({ sessionID })).pipe( + runOpenCodeSdk("session.get", (signal) => + context.client.session.get({ sessionID }, { signal }), + ).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "session.get", + detail: "OpenCode session ancestry lookup did not complete within 10 seconds.", + }), + ), + }), Effect.catchIf( (cause) => isOpenCodeNotFound(cause), () => Effect.succeed(undefined), @@ -1610,6 +1630,52 @@ export function makeOpenCodeAdapter( return false; }); + const openPermissionRequest = Effect.fn("openPermissionRequest")(function* ( + context: OpenCodeSessionContext, + request: PermissionRequest, + raw: unknown, + ) { + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + context.emittedTerminalRequestIds.has(request.id) || + context.pendingPermissions.has(request.id) + ) { + return; + } + const patterns = request.patterns.filter((pattern) => pattern !== "*"); + const detail = + request.permission === "bash" && patterns.length > 0 + ? patterns.join("\n") + : [request.permission.replaceAll("_", " "), ...patterns].join("\n"); + context.autoRepliedRequestIds.delete(request.id); + context.pendingPermissions.set(request.id, request); + emitUnsafe({ + ...base, + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(request.permission), + detail, + args: request.metadata, + options: [ + { decision: "accept", label: "Allow once" }, + { + decision: "acceptForSession", + label: "Allow for workspace", + warning: "Applies to matching requests in other OpenCode sessions in this workspace.", + }, + { decision: "decline", label: "Deny" }, + ], + }, + }); + }); + // Full access means the user already granted everything, but two upstream // paths never consult the session ruleset we send: doom-loop detection // (evaluated against the agent ruleset only) and subagent sessions (which @@ -1622,15 +1688,12 @@ export function makeOpenCodeAdapter( const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( context: OpenCodeSessionContext, request: PermissionRequest, + raw: unknown, ) { - // Mark before awaiting: retry and recovery fibers re-enter the ask path, - // and the matching `permission.replied` can arrive, while the SDK call - // is in flight. Marked ids skip the ask and swallow the terminal event. - context.resolvedRequestIds.add(request.id); - context.autoRepliedRequestIds.add(request.id); - const replied = yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ requestID: request.id, reply: "once" }), + const replied = yield* runOpenCodeSdk("permission.reply", (signal) => + context.client.permission.reply({ requestID: request.id, reply: "once" }, { signal }), ).pipe( + Effect.timeout("10 seconds"), Effect.as(true), Effect.orElseSucceed(() => false), ); @@ -1638,9 +1701,8 @@ export function makeOpenCodeAdapter( // Fall back to the dialog. The id stays resolved so a recovered copy // of this ask cannot reopen after the user answers; // `pendingPermissions` gates re-asks while the dialog is open. - context.autoRepliedRequestIds.delete(request.id); + yield* openPermissionRequest(context, request, raw); } - return replied; }); const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( @@ -1651,39 +1713,26 @@ export function makeOpenCodeAdapter( if (context.resolvedRequestIds.has(event.properties.id)) { return; } + if (context.activeTurnId === undefined && context.reconcileIdleStatus) { + context.resolvedRequestIds.add(event.properties.id); + return; + } if (event.type === "permission.asked") { const request = event.properties; if (context.pendingPermissions.has(request.id)) { return; } - if ( - context.session.runtimeMode === "full-access" && - (yield* autoReplyFullAccess(context, request)) - ) { - return; - } - const base = yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - }); - // No yield between this check and the publish: a terminal - // `permission.replied` delivered on the pump in between would leave a - // dialog that can never close. - if (context.emittedTerminalRequestIds.has(request.id)) { + if (context.session.runtimeMode === "full-access") { + // Reply outside the event pump so a slow HTTP response cannot hide + // progress, terminal replies, or the acknowledgment for Stop. + context.resolvedRequestIds.add(request.id); + context.autoRepliedRequestIds.add(request.id); + yield* autoReplyFullAccess(context, request, raw).pipe( + Effect.forkIn(context.sessionScope), + ); return; } - context.pendingPermissions.set(request.id, request); - emitUnsafe({ - ...base, - type: "request.opened", - payload: { - requestType: mapPermissionToRequestType(request.permission), - detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission, - args: request.metadata, - }, - }); + yield* openPermissionRequest(context, request, raw); return; } @@ -1691,14 +1740,19 @@ export function makeOpenCodeAdapter( if (context.pendingQuestions.has(request.id)) { return; } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + const stopped = yield* Ref.get(context.stopped); + if (stopped || context.resolvedRequestIds.has(request.id)) { + return; + } context.pendingQuestions.set(request.id, request); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - })), + emitUnsafe({ + ...base, type: "user-input.requested", payload: { questions: normalizeQuestionRequest(request) }, }); @@ -1719,26 +1773,32 @@ export function makeOpenCodeAdapter( const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* ( context: OpenCodeSessionContext, event: OpenCodeTerminalRequestEvent, + raw: unknown = event, ) { const requestId = event.properties.requestID; if (context.emittedTerminalRequestIds.has(requestId)) { return; } - context.emittedTerminalRequestIds.add(requestId); if (context.autoRepliedRequestIds.delete(requestId)) { + context.emittedTerminalRequestIds.add(requestId); return; } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw, + }); + if (context.emittedTerminalRequestIds.has(requestId)) return; + context.emittedTerminalRequestIds.add(requestId); if (event.type === "permission.replied") { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId, - raw: event, - })), + const request = context.pendingPermissions.get(requestId); + context.pendingPermissions.delete(requestId); + emitUnsafe({ + ...base, type: "request.resolved", payload: { - requestType: "unknown", + requestType: request ? mapPermissionToRequestType(request.permission) : "unknown", decision: mapPermissionDecision(event.properties.reply), }, }); @@ -1746,6 +1806,7 @@ export function makeOpenCodeAdapter( } const request = context.pendingQuestions.get(requestId); + context.pendingQuestions.delete(requestId); const answers = event.type === "question.replied" && request ? Object.fromEntries( @@ -1755,18 +1816,73 @@ export function makeOpenCodeAdapter( ]), ) : {}; - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId, - raw: event, - })), + emitUnsafe({ + ...base, type: "user-input.resolved", payload: { answers }, }); }); + const closePendingOpenCodeRequests = Effect.fn("closePendingOpenCodeRequests")(function* ( + context: OpenCodeSessionContext, + permissions: ReadonlyArray, + questions: ReadonlyArray, + raw: unknown, + ) { + for (const request of permissions) { + if (!context.pendingPermissions.has(request.id)) continue; + yield* resolvePendingOpenCodeRequest(context, request.id); + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + if (context.emittedTerminalRequestIds.has(request.id)) continue; + context.pendingPermissions.delete(request.id); + context.emittedTerminalRequestIds.add(request.id); + emitUnsafe({ + ...base, + type: "request.resolved", + payload: { requestType: mapPermissionToRequestType(request.permission) }, + }); + } + for (const request of questions) { + if (!context.pendingQuestions.has(request.id)) continue; + yield* resolvePendingOpenCodeRequest(context, request.id); + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + if (context.emittedTerminalRequestIds.has(request.id)) continue; + context.pendingQuestions.delete(request.id); + context.emittedTerminalRequestIds.add(request.id); + emitUnsafe({ ...base, type: "user-input.resolved", payload: { answers: {} } }); + } + }); + + const clearPendingOpenCodeRequests = Effect.fn("clearPendingOpenCodeRequests")(function* ( + context: OpenCodeSessionContext, + raw: unknown, + ) { + context.pendingRequestRecovery = undefined; + for (const requestId of context.requestRelationRetries.keys()) { + yield* resolvePendingOpenCodeRequest(context, requestId); + } + for (const requestId of context.autoRepliedRequestIds) { + context.emittedTerminalRequestIds.add(requestId); + } + context.autoRepliedRequestIds.clear(); + yield* closePendingOpenCodeRequests( + context, + [...context.pendingPermissions.values()], + [...context.pendingQuestions.values()], + raw, + ); + }); + const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* ( context: OpenCodeSessionContext, event: OpenCodeRoutedRequestEvent, @@ -1854,10 +1970,21 @@ export function makeOpenCodeAdapter( const run = Effect.gen(function* () { let retryCount = 0; while (context.pendingRequestRecovery === recovery) { - const responses = yield* Effect.all({ - permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()), - questions: runOpenCodeSdk("question.list", () => context.client.question.list()), - }).pipe( + // Only requests pending before the snapshot can be closed by it. + const priorPermissions = [...context.pendingPermissions.values()]; + const priorQuestions = [...context.pendingQuestions.values()]; + const responses = yield* Effect.all( + { + permissions: runOpenCodeSdk("permission.list", (signal) => + context.client.permission.list(undefined, { signal }), + ), + questions: runOpenCodeSdk("question.list", (signal) => + context.client.question.list(undefined, { signal }), + ), + }, + { concurrency: 2 }, + ).pipe( + Effect.timeout("10 seconds"), Effect.match({ onFailure: (cause) => ({ type: "failure" as const, cause }), onSuccess: (value) => ({ type: "success" as const, value }), @@ -1901,6 +2028,14 @@ export function makeOpenCodeAdapter( yield* Effect.sleep(`${delayMs} millis`); continue; } + const permissionIds = new Set(permissions.map((request) => request.id)); + const questionIds = new Set(questions.map((request) => request.id)); + yield* closePendingOpenCodeRequests( + context, + priorPermissions.filter((request) => !permissionIds.has(request.id)), + priorQuestions.filter((request) => !questionIds.has(request.id)), + { type: "pending-requests.recovered" }, + ); yield* Effect.forEach( permissions, (request) => @@ -1970,6 +2105,9 @@ export function makeOpenCodeAdapter( yield* schedulePendingRequestRecovery(context); if (!isFirstConnection) { yield* schedulePromptAdmissionRecovery(context, event); + if (context.activeTurnId !== undefined && context.promptAdmission === undefined) { + yield* scheduleIdleReconciliation(context, context.activeTurnId, event); + } } return; } @@ -2046,6 +2184,7 @@ export function makeOpenCodeAdapter( context.awaitingBusyAfterInterruption) && (event.type === "message.part.delta" || event.type === "message.part.updated" || + event.type === "todo.updated" || (event.type === "message.updated" && event.properties.info.role === "assistant")); if (suppressInterruptedParentOutput) { return; @@ -2125,7 +2264,11 @@ export function makeOpenCodeAdapter( case "message.part.delta": { const existingPart = context.partById.get(event.properties.partID); - if (!existingPart) { + if ( + !existingPart || + (existingPart.type !== "text" && existingPart.type !== "reasoning") || + event.properties.field !== "text" + ) { break; } const role = messageRoleForPart(context, existingPart); @@ -2180,7 +2323,9 @@ export function makeOpenCodeAdapter( if (part.type === "tool") { const itemType = toToolLifecycleItemType(part.tool); const title = - part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + part.state.status === "running" || part.state.status === "completed" + ? (part.state.title ?? part.tool) + : part.tool; const detail = detailFromToolPart(part); const payload = { itemType, @@ -2194,6 +2339,14 @@ export function makeOpenCodeAdapter( data: { tool: part.tool, state: part.state, + ...(typeof part.state.input.command === "string" + ? { command: part.state.input.command } + : {}), + ...(itemType === "file_change" ? { input: part.state.input } : {}), + ...(part.state.status === "completed" && + (itemType === "command_execution" || itemType === "mcp_tool_call") + ? { result: part.state.output } + : {}), }, }; const runtimeEvent: ProviderRuntimeEvent = { @@ -2224,7 +2377,6 @@ export function makeOpenCodeAdapter( } case "permission.replied": { - context.pendingPermissions.delete(event.properties.requestID); yield* emitTerminalOpenCodeRequest(context, event); break; } @@ -2236,18 +2388,45 @@ export function makeOpenCodeAdapter( case "question.replied": { yield* emitTerminalOpenCodeRequest(context, event); - context.pendingQuestions.delete(event.properties.requestID); break; } case "question.rejected": { - context.pendingQuestions.delete(event.properties.requestID); yield* emitTerminalOpenCodeRequest(context, event); break; } + case "todo.updated": { + if (turnId === undefined) break; + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + }); + // Session-wide task updates must not reopen progress after a turn ends. + if (context.activeTurnId !== turnId) break; + emitUnsafe({ + ...base, + type: "turn.plan.updated", + payload: { + plan: event.properties.todos + .filter((todo) => todo.status !== "cancelled") + .map((todo) => ({ + step: trimText(todo.content) ?? "Task", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })), + }, + }); + break; + } + case "session.status": { - if (event.properties.status.type === "busy") { + if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { if (turnId === undefined) { break; } @@ -2272,7 +2451,7 @@ export function makeOpenCodeAdapter( })), type: "runtime.warning", payload: { - message: event.properties.status.message, + message: `OpenCode retry ${event.properties.status.attempt}: ${event.properties.status.message}`, detail: event.properties.status, }, }); @@ -2335,6 +2514,7 @@ export function makeOpenCodeAdapter( context.activeAgent = undefined; context.activeVariant = undefined; context.reconcileIdleStatus = false; + yield* schedulePendingRequestRecovery(context); yield* updateProviderSession( context, { @@ -2388,9 +2568,29 @@ export function makeOpenCodeAdapter( // shutdown) and cancels the in-flight `event.subscribe` fetch so // the async iterable unwinds cleanly. const eventsAbortController = new AbortController(); - yield* Scope.addFinalizer( - context.sessionScope, - Effect.sync(() => eventsAbortController.abort()), + let lastStreamError: unknown; + let warnedAboutDisconnect = false; + const streamErrors = yield* Queue.unbounded(); + yield* Scope.addFinalizer(context.sessionScope, Queue.shutdown(streamErrors)); + yield* Stream.fromQueue(streamErrors).pipe( + Stream.runForEach((cause) => + Effect.gen(function* () { + if (warnedAboutDisconnect) return; + warnedAboutDisconnect = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + })), + type: "runtime.warning", + payload: { + message: "OpenCode connection lost. Reconnecting.", + detail: openCodeRuntimeErrorDetail(cause), + }, + }); + }), + ), + Effect.forkIn(context.sessionScope), ); // Fibers forked into `context.sessionScope` are interrupted @@ -2399,6 +2599,10 @@ export function makeOpenCodeAdapter( runOpenCodeSdk("event.subscribe", () => context.client.event.subscribe(undefined, { signal: eventsAbortController.signal, + onSseError: (cause) => { + lastStreamError = cause; + Queue.offerUnsafe(streamErrors, cause); + }, }), ), (subscription) => @@ -2410,7 +2614,13 @@ export function makeOpenCodeAdapter( detail: openCodeRuntimeErrorDetail(cause), cause, }), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ).pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") lastStreamError = undefined; + if (event.type === "server.connected") warnedAboutDisconnect = false; + return handleSubscribedEvent(context, event); + }), + ), ).pipe( Effect.exit, Effect.flatMap((exit) => @@ -2420,12 +2630,14 @@ export function makeOpenCodeAdapter( if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { return; } - if (Exit.isFailure(exit)) { - yield* emitUnexpectedExit( - context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), - ); - } + yield* emitUnexpectedExit( + context, + Exit.isFailure(exit) + ? openCodeRuntimeErrorDetail(Cause.squash(exit.cause)) + : lastStreamError !== undefined + ? `OpenCode event stream disconnected: ${openCodeRuntimeErrorDetail(lastStreamError)}` + : "OpenCode event stream ended unexpectedly. Send another message to reconnect.", + ); }), ), Effect.forkIn(context.sessionScope), @@ -2444,6 +2656,12 @@ export function makeOpenCodeAdapter( Effect.forkIn(context.sessionScope), ); } + // Scope finalizers run in reverse order. Abort the pending read before + // interrupting the pump, whose iterator.return() waits for that read. + yield* Scope.addFinalizer( + context.sessionScope, + Effect.sync(() => eventsAbortController.abort()), + ); }); const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( @@ -3259,6 +3477,7 @@ export function makeOpenCodeAdapter( } else { context.cancellation = undefined; context.reconcileIdleStatus = true; + yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); } } yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); @@ -3269,20 +3488,52 @@ export function makeOpenCodeAdapter( "respondToRequest", )(function* (threadId, requestId, decision) { const context = yield* ensureSessionContext(sessions, threadId); - if (!context.pendingPermissions.has(requestId)) { + const request = context.pendingPermissions.get(requestId); + if (!request) { + if (context.emittedTerminalRequestIds.has(requestId)) return; return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "permission.reply", - detail: `Unknown pending permission request: ${requestId}`, + detail: + context.pendingRequestRecovery || context.requestRelationRetries.has(requestId) + ? "OpenCode is still loading this permission request. Try again." + : `Unknown pending permission request: ${requestId}`, }); } - yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), + const reply = toOpenCodePermissionReply(decision); + yield* runOpenCodeSdk("permission.reply", (signal) => + context.client.permission.reply( + { + requestID: requestId, + reply, + }, + { signal }, + ), + ).pipe( + Effect.mapError(toRequestError), + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: "OpenCode permission reply did not complete within 10 seconds.", + }), + ), }), - ).pipe(Effect.mapError(toRequestError)); + ); + yield* resolvePendingOpenCodeRequest(context, requestId); + yield* emitTerminalOpenCodeRequest( + context, + { + id: `reply:${requestId}`, + type: "permission.replied", + properties: { sessionID: request.sessionID, requestID: requestId, reply }, + }, + { type: "permission.reply", requestID: requestId, reply }, + ); }); const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( @@ -3291,19 +3542,54 @@ export function makeOpenCodeAdapter( const context = yield* ensureSessionContext(sessions, threadId); const request = context.pendingQuestions.get(requestId); if (!request) { + if (context.emittedTerminalRequestIds.has(requestId)) return; return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "question.reply", - detail: `Unknown pending user-input request: ${requestId}`, + detail: + context.pendingRequestRecovery || context.requestRelationRetries.has(requestId) + ? "OpenCode is still loading this question. Try again." + : `Unknown pending user-input request: ${requestId}`, }); } - yield* runOpenCodeSdk("question.reply", () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), + const questionAnswers = toOpenCodeQuestionAnswers(request, answers); + yield* runOpenCodeSdk("question.reply", (signal) => + context.client.question.reply( + { + requestID: requestId, + answers: questionAnswers, + }, + { signal }, + ), + ).pipe( + Effect.mapError(toRequestError), + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: "OpenCode question reply did not complete within 10 seconds.", + }), + ), }), - ).pipe(Effect.mapError(toRequestError)); + ); + yield* resolvePendingOpenCodeRequest(context, requestId); + yield* emitTerminalOpenCodeRequest( + context, + { + id: `reply:${requestId}`, + type: "question.replied", + properties: { + sessionID: request.sessionID, + requestID: requestId, + answers: questionAnswers, + }, + }, + { type: "question.reply", requestID: requestId }, + ); }); const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts index 584a9d80fb9c..680032de06b4 100644 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ b/apps/server/src/provider/opencodeRuntime.environment.test.ts @@ -1,12 +1,24 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { describe, expect, it } from "vite-plus/test"; import { + OpenCodeRuntime, OpenCodeRuntimeError, + OpenCodeRuntimeLive, resolveOpenCodeConfigContent, resolveOpenCodeServerPassword, verifyOpenCodeServerVersion, @@ -150,3 +162,78 @@ describe("verifyOpenCodeServerVersion", () => { }).pipe(Effect.provide(TestClock.layer())), ); }); + +describe("OpenCode server output", () => { + effectIt.live( + "drains stdout and stderr after startup so server requests can finish", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" }); + const isWindows = platform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + + yield* fs.writeFileString( + scriptPath, + `import { createServer } from "node:http"; +const writeOutput = (stream) => new Promise((resolve, reject) => { + stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); +}); +const server = createServer(async (request, response) => { + if (request.url.startsWith("/global/health")) { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ healthy: true, version: "1.14.19" })); + return; + } + await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]); + response.end("drained"); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n"); +}); +`, + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime; + const server = yield* runtime.startOpenCodeServerProcess({ + binaryPath, + directory: tempDir, + port: 0, + environment: { + ...environment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + const response = yield* HttpClient.get(`${server.url}/output`); + + expect(yield* response.text).toBe("drained"); + expect(yield* server.isRunning).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide([ + OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)), + FetchHttpClient.layer, + ]), + ), + 10_000, + ); +}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 2a878a24ab8e..39ffec7436d4 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -1,12 +1,14 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import { HostProcessEnvironment, HostProcessExecutablePath, @@ -18,6 +20,44 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { + it.effect("aborts pending SDK requests when inventory loading is interrupted", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const started = yield* Queue.make(); + const aborted = yield* Queue.make(); + const client = createOpencodeClient({ + baseUrl: "http://opencode.test", + fetch: Object.assign( + (input: string | Request | URL) => { + const request = input instanceof Request ? input : new Request(input.toString()); + return new Promise((_resolve, reject) => { + request.signal.addEventListener( + "abort", + () => { + Queue.offerUnsafe(aborted, new URL(request.url).pathname); + reject(request.signal.reason); + }, + { once: true }, + ); + Queue.offerUnsafe(started, undefined); + }); + }, + { preconnect: () => undefined }, + ), + }); + + const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); + yield* Queue.takeN(started, 3); + yield* Fiber.interrupt(inventoryFiber); + + NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ + "/agent", + "/provider", + "/skill", + ]); + }), + ); + it.effect("keeps provider inventory when agent discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts index be2696d7e100..a6ae1fbe0437 100644 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -1,15 +1,21 @@ import * as NodeAssert from "node:assert/strict"; +import * as RegExpUtils from "effect/RegExp"; import { describe, it } from "vite-plus/test"; -import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; +import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; function actionFor( runtimeMode: Parameters[0], permission: string, + target = "*", ) { - return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission) - ?.action; + // OpenCode uses the last matching rule. Its wildcards match directory separators. + return buildOpenCodePermissionRules(runtimeMode).findLast( + (rule) => + (rule.permission === "*" || rule.permission === permission) && + new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), + )?.action; } describe("buildOpenCodePermissionRules", () => { @@ -27,12 +33,38 @@ describe("buildOpenCodePermissionRules", () => { NodeAssert.equal(actionFor("auto", "edit"), "ask"); }); - it("keeps asking for everything else in the auto modes", () => { - for (const runtimeMode of ["auto-accept-edits", "auto"] as const) { + it("allows workspace reads and task updates without asking in supervised modes", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { + NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); + } + } + }); + + it("preserves OpenCode's environment-file approval rules", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + for (const target of [ + ".env", + ".env.local", + "config/service.env", + "config/service.env.local", + ]) { + NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); + } + for (const target of [".env.example", "config/service.env.example"]) { + NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); + } + } + }); + + it("still asks before commands, network access, external directories and unknown tools", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "*"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask"); } }); @@ -43,3 +75,15 @@ describe("buildOpenCodePermissionRules", () => { ]); }); }); + +describe("toOpenCodePermissionReply", () => { + it.each([ + ["accept", "once"], + ["acceptForSession", "always"], + ["acceptAlways", "always"], + ["decline", "reject"], + ["cancel", "reject"], + ] as const)("maps %s to %s", (decision, reply) => { + NodeAssert.equal(toOpenCodePermissionReply(decision), reply); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index afd806e5666e..19725d9472ca 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -80,6 +80,7 @@ export function resolveOpenCodeServerPassword( const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; @@ -494,8 +495,19 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi // reviewer, OpenCode among them, fall back to Supervised for that mode. const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; + // Session rules override OpenCode's agent defaults. Allow reads and task + // updates, but keep its default approval rules for environment files. return [ { permission: "*", pattern: "*", action: "ask" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*.env", action: "ask" }, + { permission: "read", pattern: "*.env.*", action: "ask" }, + { permission: "read", pattern: "*.env.example", action: "allow" }, + { permission: "glob", pattern: "*", action: "allow" }, + { permission: "grep", pattern: "*", action: "allow" }, + { permission: "lsp", pattern: "*", action: "allow" }, + { permission: "skill", pattern: "*", action: "allow" }, + { permission: "todowrite", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "ask" }, { permission: "edit", pattern: "*", action: editAction }, { permission: "webfetch", pattern: "*", action: "ask" }, @@ -514,6 +526,7 @@ export function toOpenCodePermissionReply( case "accept": return "once"; case "acceptForSession": + case "acceptAlways": return "always"; case "decline": case "cancel": @@ -707,18 +720,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); yield* Scope.addFinalizer(runtimeScope, terminateChild); - const stdoutRef = yield* Ref.make(""); - const stderrRef = yield* Ref.make(""); + const stdoutRef = yield* Ref.make(""); + const stderrRef = yield* Ref.make(""); const readyDeferred = yield* Deferred.make(); const setReadyFromStdoutChunk = (chunk: string) => - Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( - Effect.flatMap((nextStdout) => { - const parsed = parseServerUrlFromOutput(nextStdout); - return parsed - ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) - : Effect.void; - }), + Ref.modify(stdoutRef, (stdout) => { + if (stdout === null) { + return [null, null] as const; + } + const nextStdout = `${stdout}${chunk}`; + return [ + parseServerUrlFromOutput(nextStdout), + nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ] as const; + }).pipe( + Effect.flatMap((parsed) => + parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, + ), ); const stdoutFiber = yield* child.stdout.pipe( @@ -729,7 +748,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const stderrFiber = yield* child.stderr.pipe( Stream.decodeText(), - Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), + Stream.runForEach((chunk) => + Ref.update(stderrRef, (stderr) => + stderr === null + ? null + : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ), + ), Effect.ignore, Effect.forkIn(runtimeScope), ); @@ -737,8 +762,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const exitFiber = yield* child.exitCode.pipe( Effect.flatMap((code) => Effect.gen(function* () { - const stdout = yield* Ref.get(stdoutRef); - const stderr = yield* Ref.get(stderrRef); + const stdout = (yield* Ref.get(stdoutRef)) ?? ""; + const stderr = (yield* Ref.get(stderrRef)) ?? ""; const exitCode = Number(code); yield* Deferred.fail( readyDeferred, @@ -764,14 +789,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), ); - // Startup-time fibers are no longer needed once ready has resolved (either - // way). The exit fiber is only interrupted on failure; on success it keeps - // the caller's `exitCode` effect observable until the scope closes. - yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); - yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); + if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { + yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); + } if (Exit.isFailure(readyExit)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); const squashed = Cause.squash(readyExit.cause); return yield* ensureRuntimeError( "startOpenCodeServerProcess", @@ -782,13 +804,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const readyOption = readyExit.value; if (Option.isNone(readyOption)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, }); } + // Keep draining both pipes until the process scope closes. Stopping the + // readers can block OpenCode when its output buffers fill. Startup output + // is no longer needed, so discard later output instead of retaining it. + yield* Ref.set(stdoutRef, null); + yield* Ref.set(stderrRef, null); + const url = readyOption.value; const version = yield* verifyOpenCodeServerVersion( createOpenCodeSdkClient({ @@ -854,7 +881,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }; const loadProviders = (client: OpencodeClient) => - runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( + runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( Effect.filterMapOrFail( (list) => list.data @@ -870,7 +897,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const loadAgents = (client: OpencodeClient) => - runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( + runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( Effect.map((result) => result.data ?? []), Effect.orElseSucceed((): ReadonlyArray => []), ); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 5dade8459e0f..becd8d91f80c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -62,6 +62,37 @@ function makeActivity(overrides: { } describe("derivePendingApprovals", () => { + it.each([{}, { requestType: "unknown" }])( + "exposes legacy OpenCode approvals without a known request kind: %j", + (legacyPayload) => { + const requested = makeActivity({ + kind: "approval.requested", + payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, + }); + + expect(derivePendingApprovals([requested])).toEqual([ + { + requestId: "per-legacy", + requestKind: "command", + createdAt: requested.createdAt, + detail: "*", + }, + ]); + }, + ); + + it.each(["tool_user_input", "auth_tokens_refresh"])( + "does not turn %s into an approval", + (requestType) => { + const activity = makeActivity({ + kind: "approval.requested", + payload: { requestId: "not-an-approval", requestType }, + }); + + expect(derivePendingApprovals([activity])).toEqual([]); + }, + ); + it("tracks open approvals and removes resolved ones", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -90,7 +121,7 @@ describe("derivePendingApprovals", () => { kind: "approval.requested", summary: "File-change approval requested", tone: "approval", - payload: { requestId: "req-2", requestKind: "file-change" }, + payload: { requestId: "req-2", requestType: "unknown" }, }), ]; @@ -199,7 +230,7 @@ describe("derivePendingApprovals", () => { tone: "approval", payload: { requestId: "req-stale-1", - requestKind: "command", + requestType: "unknown", }, }), makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index c6c7410bebea..b70b2a1ba7f6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -464,10 +464,16 @@ export function derivePendingApprovals( ? payload.options.filter(isProviderApprovalOption) : undefined; - if (activity.kind === "approval.requested" && requestId && requestKind) { + if ( + activity.kind === "approval.requested" && + requestId && + payload?.requestType !== "tool_user_input" && + payload?.requestType !== "auth_tokens_refresh" + ) { openByRequestId.set(requestId, { requestId, - requestKind, + // Older OpenCode requests can have no recognized approval kind. + requestKind: requestKind ?? "command", createdAt: activity.createdAt, ...(detail ? { detail } : {}), ...(appName ? { appName } : {}), diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index a066b038833d..141aa2926add 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -17,14 +17,45 @@ With a server URL, T3 Code connects to that external server and uses only the pa provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. OpenCode uses this password for HTTP Basic authentication. +## Approvals + +In **Supervised** and **Auto** modes, OpenCode can read normal project files, search files, load +skills, and update its task list without approval. Files such as `.env` and `.env.local` still +require approval. `.env.example` does not. OpenCode does not have an AI approval reviewer, so +**Auto** uses the same permission rules as **Supervised**. + +OpenCode asks before it runs commands, edits files, accesses the web, or accesses directories +outside the workspace. **Auto-accept edits** also permits file edits without approval. +**Full access** permits all these actions. Questions that need your answer can still appear. + +An **Approval** badge means OpenCode needs a decision. Open the thread to see the action and +choose one of these options: + +- **Allow once** permits this request. +- **Allow for workspace** permits matching requests in other OpenCode sessions in the same + workspace. It is not limited to the current thread. +- **Deny** rejects this request. Use **Stop** to stop the whole turn. + +If a connection error prevents the reply, the approval stays available so you can try again. + +## Progress + +T3 Code shows OpenCode's response text and tool results while work runs. The web and desktop apps +also show its task-list progress. A task-list update does not require approval. + +If the OpenCode connection closes unexpectedly, T3 Code shows an error. Send another prompt to +reconnect to the same OpenCode session. + ## Stop a turn When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions. T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It -does not stop unrelated OpenCode sessions. +does not stop unrelated OpenCode sessions. After Stop succeeds, pending approvals and questions +are cleared. -Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an -OpenCode session, it also tries to stop the child sessions, but this teardown is best effort. +Stop reports an error if OpenCode cannot stop the main session or list or stop a child session. +When T3 Code closes an OpenCode session, it also tries to stop the child sessions, but this +teardown is best effort. ## Refresh the model list diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index cc5b1c910539..f3febf5e756d 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -15,6 +15,40 @@ import { } from "./presentation.js"; describe("summarizeToolGroup", () => { + it.each(["command", "file-read", "file-change"])( + "keeps %s approvals out of tool execution counts", + (requestKind) => { + const approvals = [ + { + label: "Approval requested", + sourceActivityKind: "approval.requested", + tone: "info", + requestKind, + }, + { + label: "Approval resolved", + sourceActivityKind: "approval.resolved", + tone: "info", + requestKind, + }, + { + label: "Provider approval response failed", + sourceActivityKind: "provider.approval.respond.failed", + tone: "error", + }, + ] satisfies WorkLogPresentationEntry[]; + + expect( + summarizeToolGroup([ + ...approvals, + { label: "Read", tone: "tool", itemType: "dynamic_tool_call" }, + ]), + ).toBe("Received 3 updates and used 1 tool"); + expect(summarizeToolGroup(approvals)).toBe("Received 3 updates"); + expect(toolGroupSummaryKind(approvals)).toBe("update"); + }, + ); + it("deduplicates named sources ahead of ordinary actions", () => { const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const }; expect( diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index e27a2d318fef..d47f44566452 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -313,6 +313,13 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): } export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { + if ( + entry.sourceActivityKind === "approval.requested" || + entry.sourceActivityKind === "approval.resolved" || + entry.sourceActivityKind === "provider.approval.respond.failed" + ) { + return "update"; + } if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; if ( entry.requestKind === "file-read" || From caa8a0db98f9d32e98a1645caa7f7dd37b14f187 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:18:31 -0700 Subject: [PATCH 15/18] fix(desktop): quit immediately on a second shortcut press (#9657) --- apps/desktop/src/window/QuitHold.test.ts | 75 ++++++++++++++----- apps/desktop/src/window/QuitHold.ts | 35 +++++---- apps/web/src/components/QuitHoldOverlay.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 2 +- docs/user/keybindings.md | 13 ++++ 5 files changed, 91 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index c4bf2f34b0a1..58809d8eb14d 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -252,24 +252,31 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([]); }); - it("honors a quick double press when both key releases beat their mode reads", async () => { - const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; - const harness = makeHarness({ - getMode: () => new Promise((resolve) => resolvers.push(resolve)), - }); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - - resolvers[1]?.("double-click"); - await Promise.resolve(); - await Promise.resolve(); - - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([]); - }); + it.each(["direct", "hold", "double-click"] as const)( + "quits on a quick second press without waiting for a pending %s mode read", + async (mode) => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[0]?.(mode); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }, + ); it("discards a stale mode resolution from a superseded press", async () => { // Press #1's mode is still pending when the user releases and @@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); + it("quits on a quick second press in hold mode when the first release is unseen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.concealWindow).not.toHaveBeenCalled(); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); + }); + + it("does not count auto-repeat as a second press", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([HOLD_DOWN]); + }); + + it("does not count a released tap after another shortcut interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + await harness.send(makeInput({ key: "c" })); + vi.advanceTimersByTime(100); + await harness.send(makeInput({})); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + }); + it("cancels the hold when another key interrupts it", async () => { const harness = makeHarness(); await harness.send(makeInput({})); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 4095e3d4354b..a995184ddd70 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500; // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // repeats stop so they cannot reach the next app. Keyboards with -// auto-repeat disabled fall back to the application menu Quit action. +// auto-repeat disabled must use a double press or the application menu Quit action. +// Supporting holds without repeats requires a native physical key-state check. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -52,8 +53,8 @@ export function makeQuitShortcutHandler( let lastRepeatAt = 0; let repeatCadenceMs = 0; // Incremented when a press is superseded or explicitly cancelled. A plain - // key release does not invalidate its pending mode read: direct mode and a - // completed second press must still be honored after that read settles. + // key release does not invalidate its pending mode read: a direct-mode + // press must still quit after that read settles. let generation = 0; const clearWatchdog = () => { @@ -64,9 +65,9 @@ export function makeQuitShortcutHandler( }; const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (cancelPendingMode) generation += 1; if (!holding && !notified) return; const keepHint = keepDoublePressHint && mode === "double-click" && notified; - if (cancelPendingMode) generation += 1; holding = false; armed = false; quitOnRelease = false; @@ -85,6 +86,7 @@ export function makeQuitShortcutHandler( // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); + lastPressAt = 0; options.quit(); }; @@ -138,13 +140,10 @@ export function makeQuitShortcutHandler( // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Any other key (or an extra modifier) pressed mid-hold breaks the - // gesture; without this the hold timer keeps running through the - // interruption and the next qualifying repeat would quit early. The - // interrupted press also stops counting toward a double press, but only - // here, not in release(), which runs mid-restart on an unseen-release - // re-press and must not wipe that press's own tap timestamp. - if ((holding || notified) && !input.isAutoRepeat) { + // Other keys cancel the hold and the first tap, even after release. + // Keep this separate from release(), which also runs when a fresh Q + // keydown follows a keyUp that macOS did not deliver. + if (!input.isAutoRepeat) { lastPressAt = 0; release(); } @@ -171,6 +170,13 @@ export function makeQuitShortcutHandler( if (holding || notified) release(); generation += 1; + // Every mode accepts two presses. Quit before reading settings so a slow + // read cannot delay the second press. Repeats never reach this branch. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { + quitNow(); + return; + } + const pressGeneration = generation; holding = true; heldSince = now; @@ -181,13 +187,6 @@ export function makeQuitShortcutHandler( quitNow(); return; } - // Keep a second press as an escape hatch when macOS misses the events - // that would complete a hold. - if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { - quitNow(); - return; - } - if (resolvedMode === "double-click") { const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); if (remainingMs <= 0) { diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 091fa60c2f71..2bca40f5b130 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -40,7 +40,9 @@ export function QuitHoldOverlay() { if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; const message = - visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`; + visibleMode === "hold" + ? `Hold ${shortcut} or press twice to quit` + : `Press ${shortcut} again to quit`; return (
Date: Fri, 4 Sep 2026 06:23:12 -0700 Subject: [PATCH 16/18] fix(server): update Claude Agent SDK to 0.3.260 (#9135) Co-authored-by: Claude Fable 5.1 --- apps/server/package.json | 2 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 248 +++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 115 +++++++- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 1 + 5 files changed, 357 insertions(+), 19 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index ca74368348ea..3e80321d606f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,7 +22,7 @@ "test": "vp test run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.260", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 99bb95d83c65..8fcf3cc92134 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2090,6 +2090,177 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("fails a turn when the result carries a give-up terminal_reason", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // The CLI stamps subtype success with an empty error list when it + // gives up after exhausting API retries; the terminal_reason is the + // only structured failure signal. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + errors: [], + stop_reason: null, + terminal_reason: "api_error", + session_id: "sdk-session-api-error", + uuid: "result-api-error", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "runtime.error", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "failed"); + assert.equal( + turnCompleted.payload.errorMessage, + "Claude gave up after repeated API errors.", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("fails a turn for every dead-turn terminal_reason", () => { + const reasons = [ + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + ]; + // One harness per reason: the fake query settles a single turn. + const runDeadTurn = (reason: string) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const completionFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + errors: [], + stop_reason: null, + terminal_reason: reason, + session_id: "sdk-session-dead-turn", + uuid: `result-${reason}`, + } as unknown as SDKMessage); + const completed = yield* Fiber.join(completionFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.equal(completed.value.payload.state, "failed", reason); + assert.ok(completed.value.payload.errorMessage, `${reason} carries an error message`); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }; + return Effect.forEach(reasons, runDeadTurn, { discard: true }); + }); + + it.effect("fails a turn when a success result reports a 529 overload", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 529, + result: "", + errors: [], + stop_reason: null, + session_id: "sdk-session-overload", + uuid: "result-overload", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "failed"); + assert.equal( + turnCompleted.payload.errorMessage, + "Claude API is overloaded (529). Try again shortly.", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("interruptTurn settles live tasks and closes the provider session", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -3088,7 +3259,36 @@ describe("ClaudeAdapterLive", () => { { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { + type: "system", + subtype: "control_request_progress", + request_id: "ctrl-1", + status: "started", + session_id: "session", + uuid: "crp", + }, + { + type: "system", + subtype: "worker_shutting_down", + reason: "host_exit", + session_id: "session", + uuid: "wsd", + }, + { + type: "system", + subtype: "informational", + content: "Loaded 3 skills", + level: "notice", + session_id: "session", + uuid: "info", + }, { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, + { + type: "conversation_reset", + new_conversation_id: "conv-2", + session_id: "session", + uuid: "cr", + }, { type: "system", subtype: "notification", @@ -3111,6 +3311,27 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "notif-high", } as unknown as SDKMessage); + // Warning-level informational notes and refusals without a fallback + // model surface as warning rows too. + harness.query.emit({ + type: "system", + subtype: "informational", + content: "Stop hook prevented continuation", + level: "warning", + prevent_continuation: true, + session_id: "session", + uuid: "info-warn", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "model_refusal_no_fallback", + original_model: "claude-opus-5", + request_id: null, + api_refusal_explanation: "The request was declined by the API.", + content: "Model refused", + session_id: "session", + uuid: "mrnf", + } as unknown as SDKMessage); // session_state_changed maps to the matching session states. for (const [state, uuid] of [ ["running", "ssc-run"], @@ -3141,10 +3362,15 @@ describe("ClaudeAdapterLive", () => { yield* Effect.yieldNow; const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); - // Exactly one warning: the high-priority notification. Nothing else. + // Exactly three warnings: the high-priority notification, the + // warning-level informational note, and the refusal. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), - ["context window nearly full"], + [ + "context window nearly full", + "Stop hook prevented continuation", + "The request was declined by the API.", + ], ); const sessionStates = runtimeEvents .filter((event) => event.type === "session.state.changed") @@ -4190,6 +4416,7 @@ describe("ClaudeAdapterLive", () => { { command: "pwd" }, { signal: new AbortController().signal, + requestId: "request-1", suggestions: [ { type: "setMode", @@ -4301,6 +4528,7 @@ describe("ClaudeAdapterLive", () => { { title: "hello" }, { signal: new AbortController().signal, + requestId: "request-2", suggestions: [], toolUseID: "tool-use-mcp-1", }, @@ -4327,6 +4555,7 @@ describe("ClaudeAdapterLive", () => { { command: "git status" }, { signal: new AbortController().signal, + requestId: "request-3", suggestions: [ { type: "addRules", @@ -4385,6 +4614,7 @@ describe("ClaudeAdapterLive", () => { {}, { signal: new AbortController().signal, + requestId: "request-4", toolUseID: "tool-agent-1", }, ); @@ -4409,6 +4639,7 @@ describe("ClaudeAdapterLive", () => { { pattern: "foo", path: "src" }, { signal: new AbortController().signal, + requestId: "request-5", toolUseID: "tool-grep-approval-1", }, ); @@ -4951,6 +5182,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: new AbortController().signal, + requestId: "request-6", toolUseID: "tool-exit-1", }, ); @@ -5073,7 +5305,7 @@ describe("ClaudeAdapterLive", () => { dialogKind: "resume_return", payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: "request-dialog" }, ); const requested = yield* Stream.runHead(adapter.streamEvents); @@ -5173,6 +5405,7 @@ describe("ClaudeAdapterLive", () => { const permissionPromise = canUseTool("AskUserQuestion", askInput, { signal: new AbortController().signal, + requestId: "request-7", toolUseID: "tool-ask-1", }); @@ -5299,6 +5532,7 @@ describe("ClaudeAdapterLive", () => { const permissionPromise = canUseTool("AskUserQuestion", askInput, { signal: new AbortController().signal, + requestId: "request-8", toolUseID: "tool-ask-2", }); @@ -5364,6 +5598,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: controller.signal, + requestId: "request-9", toolUseID: "tool-ask-abort", }, ); @@ -5439,6 +5674,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: controller.signal, + requestId: "request-10", toolUseID: "tool-ask-pre-aborted", }, ); @@ -5495,7 +5731,11 @@ describe("ClaudeAdapterLive", () => { }, ], }, - { signal: new AbortController().signal, toolUseID: "tool-ask-stop" }, + { + signal: new AbortController().signal, + requestId: "request-stop", + toolUseID: "tool-ask-stop", + }, ); const requestedEvent = yield* Stream.runHead(adapter.streamEvents); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5848bcc457de..75c0283eceb3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -434,10 +434,45 @@ function resultErrorsText(result: SDKResultMessage): string { * so they must never become the error banner. */ function resultUserFacingError(result: SDKResultMessage): string | undefined { - if (result.subtype === "success" || !Array.isArray(result.errors)) { - return undefined; + const listed = + result.subtype === "success" || !Array.isArray(result.errors) + ? undefined + : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); + if (listed) { + return listed; + } + // Structured failure markers for results whose error list is empty or + // diagnostic-only: an overloaded API (529) and the terminal reasons the + // CLI stamps when it gives up on a turn. + if (isOverloadedResult(result)) { + return "Claude API is overloaded (529). Try again shortly."; + } + switch (result.terminal_reason) { + case "api_error": + return "Claude gave up after repeated API errors."; + case "malformed_tool_use_exhausted": + return "Claude gave up after repeated malformed tool calls."; + case "budget_exhausted": + return "Claude stopped: the turn's token budget was exhausted."; + case "structured_output_retry_exhausted": + return "Claude could not produce the requested structured output."; + case "tool_deferred_unavailable": + return "Claude could not resume a deferred tool call: the tool is no longer available."; + case "turn_setup_failed": + return "Claude could not start the turn."; + case "blocking_limit": + return "Claude stopped: a usage limit blocked the request."; + case "rapid_refill_breaker": + return "Claude stopped: the context refilled too quickly after compaction."; + case "prompt_too_long": + return "Claude stopped: the prompt exceeds the model's context window."; + case "image_error": + return "Claude stopped: an image in the conversation could not be processed."; + case "model_error": + return "Claude stopped: the model returned an error."; + default: + return undefined; } - return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); } function isInterruptedResult(result: SDKResultMessage): boolean { @@ -1390,7 +1425,43 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( return buildUserMessage({ sdkContent }); }); +/** + * terminal_reason values the CLI classifies as dead turns: the turn died + * rather than finished, even when the result subtype is success and the + * error list is empty. Kept in sync with the messages in + * resultUserFacingError. + */ +const FAILED_TERMINAL_REASONS: ReadonlySet> = + new Set([ + "api_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + ]); + +/** + * The CLI reports repeated 529 overload failures as a success-subtype result + * with api_error_status 529 and an empty error list; the status code is the + * only structured failure signal. + */ +function isOverloadedResult(result: SDKResultMessage): boolean { + return result.subtype === "success" && result.api_error_status === 529; +} + function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if ( + isOverloadedResult(result) || + (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason)) + ) { + return "failed"; + } if (result.subtype === "success") { return "completed"; } @@ -3174,15 +3245,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Undeclared-but-real subtypes (absent from the SDK's union, so they can't // be switch cases): consumed intentionally without emitting, otherwise // they fall through to the unknown-subtype warning and surface as spurious - // error rows in client work logs. `background_tasks_changed` is a roster - // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the - // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. `vcs_state_changed` + // error rows in client work logs. `vcs_state_changed` // ({kind: commit|push|rebase}) and `code_change_published` // ({provider, url, repo}) are informational CLI notices; the work log // already shows the underlying git/gh tool calls. switch (message.subtype as string) { - case "background_tasks_changed": case "vcs_state_changed": case "code_change_published": return; @@ -3497,12 +3564,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; // Inner protocol/UX details with no T3 surface today — consumed // deliberately so they don't masquerade as unknown-subtype warnings. + // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the + // task_* lifecycle events carry the authoritative per-agent data and + // the typed background_tasks control request is the reconciliation + // source. `control_request_progress` is a liveness heartbeat for an + // in-flight control request. `worker_shutting_down` is a Remote + // Control worker notice; the session close path reports the outcome. case "model_refusal_fallback": case "local_command_output": case "plugin_install": case "commands_changed": case "memory_recall": case "elicitation_complete": + case "background_tasks_changed": + case "control_request_progress": + case "worker_shutting_down": + return; + case "informational": + // Transcript-level CLI notes. Only warnings (e.g. a Stop hook that + // refused continuation) warrant a work-log row; info/notice/ + // suggestion levels are CLI chrome. + if (message.level === "warning") { + yield* emitRuntimeWarning(context, message.content, message); + } + return; + case "model_refusal_no_fallback": + // The API refused the request and no fallback model was available. + // The terminal result reports the failed turn; this row carries the + // refusal explanation the result's error list lacks. + yield* emitRuntimeWarning( + context, + message.api_refusal_explanation?.trim() || message.content, + message, + ); return; case "permission_denied": yield* offerRuntimeEvent({ @@ -3528,7 +3622,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // handled above, so `message` narrows to never here — a new SDK // release adding a subtype fails this typecheck instead of silently // warning at runtime. The runtime fallback still catches undeclared - // wire-only subtypes (like background_tasks_changed used to be). + // wire-only subtypes (like vcs_state_changed). message satisfies never; const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( @@ -3657,7 +3751,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* handleSdkTelemetryMessage(context, message); return; // Composer prompt suggestions have no T3 surface; consumed deliberately. + // `conversation_reset` announces a CLI-side conversation id swap + // (e.g. /clear); T3 keeps its own thread identity and resume cursor. case "prompt_suggestion": + case "conversation_reset": return; default: { // Exhaustiveness guard (see handleSystemMessage): new SDK top-level diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a7e45ca8575..d69028c36094 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,8 +476,8 @@ importers: apps/server: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.260 + version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) @@ -1011,8 +1011,8 @@ packages: '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} - '@anthropic-ai/claude-agent-sdk@0.3.170': - resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} + '@anthropic-ai/claude-agent-sdk@0.3.260': + resolution: {integrity: sha512-PmABtP4Rwd6l95itQrqzguv6rS9uACqikPB9g8BPeWRKZOpy3xpEOjJLYauof3BFk2wNZnfhr0Ttx8ttcZzq0w==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -10527,7 +10527,7 @@ snapshots: '@alchemy.run/node-utils@0.0.5': {} - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7d2f9f998617..ee1bd25547f6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -90,6 +90,7 @@ minimumReleaseAgeExclude: - expo-updates@57.0.19 - expo@57.0.18 - expo-widgets@57.0.15 + - "@anthropic-ai/claude-agent-sdk@0.3.260" overrides: # The SDK always receives the user's Claude executable, so its bundled binaries are unused. From 8ac5462920c45cdee63af15b2598909736f2ec84 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:24:41 -0700 Subject: [PATCH 17/18] perf(server): stop loading message bodies for thread summaries (#9662) --- .../Layers/ProjectionPipeline.test.ts | 102 +++++++++++++++++- .../Layers/ProjectionPipeline.ts | 26 ++--- .../Layers/ProjectionPendingApprovals.ts | 21 ++++ .../Layers/ProjectionThreadMessages.test.ts | 43 ++++++++ .../Layers/ProjectionThreadMessages.ts | 23 ++++ .../Services/ProjectionPendingApprovals.ts | 5 + .../Services/ProjectionThreadMessages.ts | 5 + 7 files changed, 205 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 9e4f88a5be10..986c078c1c51 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2534,7 +2534,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("maintains shell summary fields across message and activity streams", () => + it.effect("maintains shell summaries without reading message bodies", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2742,6 +2742,106 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { updatedAt: "2026-03-01T08:00:05.000Z", }, ]); + + // Summary refreshes must not decode message bodies or attachment metadata. + yield* sql` + UPDATE projection_thread_messages + SET attachments_json = '{not-json' + WHERE thread_id = 'thread-shell-summary' + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) VALUES + ('summary-pending', 'thread-shell-summary', NULL, 'pending', NULL, + '2026-03-01T08:00:06.000Z', NULL), + ('summary-resolved', 'thread-shell-summary', NULL, 'resolved', 'accept', + '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z'), + ('summary-other-thread', 'thread-shell-summary-other', NULL, 'pending', NULL, + '2026-03-01T08:00:06.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, implemented_at, + implementation_thread_id, created_at, updated_at + ) VALUES ( + 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '# Plan', NULL, + NULL, '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z' + ) + `; + + const refreshEvents = [ + { + type: "thread.session-set", + eventId: EventId.make("evt-shell-summary-7"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:07.000Z", + commandId: CommandId.make("cmd-shell-summary-7"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-7"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + session: { + threadId: ThreadId.make("thread-shell-summary"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-01T08:00:07.000Z", + }, + }, + }, + { + type: "thread.turn-diff-completed", + eventId: EventId.make("evt-shell-summary-8"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:08.000Z", + commandId: CommandId.make("cmd-shell-summary-8"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-8"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + turnId: TurnId.make("turn-shell-summary-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-shell-summary/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("message-shell-summary-assistant"), + completedAt: "2026-03-01T08:00:08.000Z", + }, + }, + ] satisfies ReadonlyArray[0]>; + + for (const event of refreshEvents) { + yield* appendAndProject(event); + const summary = yield* sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly hasActionableProposedPlan: number; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan" + FROM projection_threads + WHERE thread_id = 'thread-shell-summary' + `; + assert.deepEqual(summary, [ + { + latestUserMessageAt: "2026-03-01T08:00:02.000Z", + pendingApprovalCount: 1, + pendingUserInputCount: 1, + hasActionableProposedPlan: 1, + }, + ]); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ee07f9fb4cdc..ff6c5866fea5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -587,26 +587,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ - projectionThreadMessageRepository.listByThreadId({ threadId }), - projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), - projectionPendingApprovalRepository.listByThreadId({ threadId }), - ]); - - let latestUserMessageAt: string | null = null; - for (const message of messages) { - if ( - message.role === "user" && - (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) - ) { - latestUserMessageAt = message.createdAt; - } - } + const [latestUserMessageAt, proposedPlans, activities, pendingApprovalCount] = + yield* Effect.all([ + projectionThreadMessageRepository.getLatestUserMessageAt({ threadId }), + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), + projectionPendingApprovalRepository.countPendingByThreadId({ threadId }), + ]); - const pendingApprovalCount = pendingApprovals.filter( - (approval) => approval.status === "pending", - ).length; const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); const hasActionableProposedPlan = deriveHasActionableProposedPlan({ latestTurnId: existingRow.value.latestTurnId, diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 3b159a9e1715..d5631cb0a62c 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -68,6 +69,16 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { `, }); + const countPendingApprovalRows = SqlSchema.findOne({ + Request: ListProjectionPendingApprovalsInput, + Result: Schema.Struct({ count: Schema.Number }), + execute: ({ threadId }) => sql` + SELECT COUNT(*) AS count + FROM projection_pending_approvals + WHERE thread_id = ${threadId} AND status = 'pending' + `, + }); + const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ Request: GetProjectionPendingApprovalInput, Result: ProjectionPendingApproval, @@ -116,6 +127,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { ), ); + const countPendingByThreadId: ProjectionPendingApprovalRepositoryShape["countPendingByThreadId"] = + (input) => + countPendingApprovalRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.countPendingByThreadId:query"), + ), + Effect.map((row) => row.count), + ); + const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => getProjectionPendingApprovalRow(input).pipe( Effect.mapError( @@ -142,6 +162,7 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { return { upsert, listByThreadId, + countPendingByThreadId, getByRequestId, deleteByRequestId, deleteByThreadId, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 30e0f42cab89..c8fa16158bae 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,49 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("finds the latest user-message time within one thread", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-latest-user-message"); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + + const messages = [ + { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, + { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, + { role: "assistant", createdAt: "2026-02-28T19:05:03.000Z" }, + { role: "system", createdAt: "2026-02-28T19:05:04.000Z" }, + ] as const; + for (const [index, message] of messages.entries()) { + yield* repository.upsert({ + messageId: MessageId.make(`latest-user-message-${index}`), + threadId, + turnId: null, + ...message, + text: "Message body", + isStreaming: false, + updatedAt: "2026-02-28T19:06:00.000Z", + }); + } + yield* repository.upsert({ + messageId: MessageId.make("latest-user-message-other-thread"), + threadId: ThreadId.make("thread-latest-user-message-other"), + turnId: null, + role: "user", + text: "Other thread", + isStreaming: false, + createdAt: "2026-02-28T19:05:05.000Z", + updatedAt: "2026-02-28T19:05:05.000Z", + }); + + assert.strictEqual( + yield* repository.getLatestUserMessageAt({ threadId }), + "2026-02-28T19:05:02.000Z", + ); + yield* repository.deleteByThreadId({ threadId }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + }), + ); + it.effect("appends streaming text and applies attachment updates", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 85e854dc6606..ce28e11b8601 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -182,6 +182,18 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const getLatestUserMessageAtRow = SqlSchema.findOne({ + Request: ListProjectionThreadMessagesInput, + Result: Schema.Struct({ + latestUserMessageAt: Schema.NullOr(ProjectionThreadMessage.fields.createdAt), + }), + execute: ({ threadId }) => sql` + SELECT MAX(created_at) AS "latestUserMessageAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} AND role = 'user' + `, + }); + const deleteProjectionThreadMessageRows = SqlSchema.void({ Request: DeleteProjectionThreadMessagesInput, execute: ({ threadId }) => @@ -219,6 +231,16 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const getLatestUserMessageAt: ProjectionThreadMessageRepositoryShape["getLatestUserMessageAt"] = ( + input, + ) => + getLatestUserMessageAtRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.getLatestUserMessageAt:query"), + ), + Effect.map((row) => row.latestUserMessageAt), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -231,6 +253,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { appendStreaming, getByMessageId, listByThreadId, + getLatestUserMessageAt, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 40b0d1ae03b6..43a829de6390 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -69,6 +69,11 @@ export interface ProjectionPendingApprovalRepositoryShape { input: ListProjectionPendingApprovalsInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** Count pending approvals without loading resolved request history. */ + readonly countPendingByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect; + /** * Read a pending approval row by request id. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 17b659a2f8da..a41737564382 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -90,6 +90,11 @@ export interface ProjectionThreadMessageRepositoryShape { input: ListProjectionThreadMessagesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read the latest user-message timestamp without loading message bodies. */ + readonly getLatestUserMessageAt: ( + input: ListProjectionThreadMessagesInput, + ) => Effect.Effect; + /** * Delete projected thread messages by thread. */ From cccd7e3c885065e925f559c5708378cdb3b51eb3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:27:42 -0700 Subject: [PATCH 18/18] perf(web): speed up terminal snapshots (#9663) --- apps/web/src/terminal/ghostty/core.test.ts | 116 ++++++++++++++++++++- apps/web/src/terminal/ghostty/core.ts | 47 ++++++--- apps/web/src/terminal/ghostty/runtime.ts | 38 ++++--- 3 files changed, 169 insertions(+), 32 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 8048d221178c..6f3254359f10 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { ghosttyCellText } from "./core"; +import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; +import { loadGhosttyRuntime } from "./runtime"; + +vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ + default: (await import("./vendor/ghostty-vt.wasm?inline")).default, +})); +vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ + default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, +})); function codepointView(codepoints: ReadonlyArray): DataView { const view = new DataView(new ArrayBuffer(codepoints.length * 4)); @@ -30,7 +38,111 @@ describe("ghosttyCellText", () => { expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]); }); + it("converts a single astral codepoint", () => { + expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe("🙂"); + }); + it("returns an empty string for empty cells", () => { expect(ghosttyCellText(codepointView([]), 0)).toBe(""); }); }); + +describe("GhosttyTerminalCore snapshots", () => { + const cores = new Set(); + + async function createCore() { + const core = await GhosttyTerminalCore.create( + 12, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + cores.add(core); + return core; + } + + afterEach(() => { + for (const core of cores) core.dispose(); + cores.clear(); + vi.restoreAllMocks(); + }); + + it("preserves styles, wide cells, and selection after shared memory grows", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + const grapheme = `e${"\u0301".repeat(64)}`; + core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m界🙂`); + const cells = core.snapshot().rowData[0]!.cells; + expect(cells[0]).toEqual({ + text: grapheme, + wide: 0, + foreground: { r: 123, g: 45, b: 67 }, + background: { r: 9, g: 8, b: 7 }, + bold: true, + italic: true, + invisible: true, + strikethrough: true, + overline: true, + underline: true, + selected: false, + }); + expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([ + { text: "界", wide: 0 }, + { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail }, + { text: "🙂", wide: 0 }, + { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail }, + ]); + + runtime.memory.grow(1); + core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 }); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0], selected: true }); + core.clearSelection(); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]); + + core.resetAndWrite("\x1b[2;7;38;2;40;100;200;48;2;12;34;56mC\x1b[0m"); + expect(core.snapshot().rowData[0]!.cells[0]).toMatchObject({ + text: "C", + foreground: { r: 22, g: 59, b: 112 }, + background: { r: 40, g: 100, b: 200 }, + bold: false, + underline: false, + selected: false, + }); + }); + + it("reuses a grown grapheme buffer and releases it on disposal", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + core.write("ASCII"); + core.snapshot(); + + const grapheme = `z${"\u0301".repeat(256)}`; + core.resetAndWrite(`${grapheme}X`); + const alloc = vi.spyOn(runtime, "alloc"); + const free = vi.spyOn(runtime, "free"); + expect( + core + .snapshot() + .rowData[0]!.cells.slice(0, 2) + .map((cell) => cell.text), + ).toEqual([grapheme, "X"]); + expect(alloc).toHaveBeenCalledTimes(1); + const allocation = alloc.mock.results[0]!; + if (allocation.type !== "return") throw new Error("Grapheme allocation did not return"); + const buffer = allocation.value; + const capacity = alloc.mock.calls[0]![0]; + + core.write("\rQ\u0301"); + alloc.mockClear(); + expect(core.snapshot().rowData[0]!.cells[0]!.text).toBe("Q\u0301"); + expect(alloc).not.toHaveBeenCalled(); + core.dispose(); + expect(free).toHaveBeenCalledWith(buffer, capacity); + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 6f6cbbe0a888..d01e20529d45 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -174,6 +174,7 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { * every codepoint into String.fromCodePoint at once. */ export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); const CHUNK_SIZE = 4_096; let text = ""; for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { @@ -206,6 +207,8 @@ export class GhosttyTerminalCore { private ptyWriterId = 0; private ptyWriter: ((data: string) => void) | null = null; private scratch = 0; + private graphemes = 0; + private graphemeCapacity = 0; private style = 0; private scrollbar = 0; private rows: GhosttyRow[] = []; @@ -878,6 +881,7 @@ export class GhosttyTerminalCore { this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); } if (this.scratch) this.runtime.free(this.scratch, 16); + if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity); for (const slot of [ this.mouseEventSlot, this.mouseEncoderSlot, @@ -944,6 +948,7 @@ export class GhosttyTerminalCore { ), ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle"); const cells: GhosttyCell[] = []; while ( cells.length < cols && @@ -951,7 +956,6 @@ export class GhosttyTerminalCore { ) { let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); - const styleSize = this.runtime.layout("GhosttyStyle").size; this.runtime.bytes(this.style, styleSize).fill(0); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); this.runtime.call( @@ -960,30 +964,30 @@ export class GhosttyTerminalCore { CELL_DATA.style, this.style, ); - const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; - if (inverse) [foreground, background] = [background, foreground]; - if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { - foreground = blend(foreground, background); - } const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); let text = ""; if (graphemeLength > 0) { const bufferSize = graphemeLength * 4; - const codepoints = this.runtime.alloc(bufferSize); + if (bufferSize > this.graphemeCapacity) { + const capacity = Math.max(bufferSize, this.graphemeCapacity * 2); + const buffer = this.runtime.alloc(capacity); + this.runtime.free(this.graphemes, this.graphemeCapacity); + this.graphemes = buffer; + this.graphemeCapacity = capacity; + } if ( this.runtime.call( "ghostty_render_state_row_cells_get", cellsIterator, CELL_DATA.graphemes, - codepoints, + this.graphemes, ) === GHOSTTY_SUCCESS ) { // Read through a DataView: the byte-array allocator guarantees no // 4-byte alignment, which a Uint32Array view would require. - const codepointView = this.runtime.view(codepoints, bufferSize); + const codepointView = this.runtime.view(this.graphemes, bufferSize); text = ghosttyCellText(codepointView, graphemeLength); } - this.runtime.free(codepoints, bufferSize); } let wide = 0; if (text.length === 0 && cells.at(-1)?.text.length) { @@ -1004,18 +1008,27 @@ export class GhosttyTerminalCore { ); wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); } + const selected = this.getCellBool(cellsIterator, CELL_DATA.selected); + // Read the style after allocation and ABI calls, which can grow WASM memory. + const styleView = this.runtime.view(this.style, styleSize); + if (styleView.getUint8(styleFields.inverse!.offset) !== 0) { + [foreground, background] = [background, foreground]; + } + if (styleView.getUint8(styleFields.faint!.offset) !== 0) { + foreground = blend(foreground, background); + } cells.push({ text, wide, foreground, background, - bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, - italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, - invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, - strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, - overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, - underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, - selected: this.getCellBool(cellsIterator, CELL_DATA.selected), + bold: styleView.getUint8(styleFields.bold!.offset) !== 0, + italic: styleView.getUint8(styleFields.italic!.offset) !== 0, + invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0, + strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0, + overline: styleView.getUint8(styleFields.overline!.offset) !== 0, + underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0, + selected, }); } while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index aca82e7cc3c0..976900fa6d84 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -23,6 +23,7 @@ export class GhosttyRuntime { readonly memory: WebAssembly.Memory; readonly layouts: TypeLayouts; private readonly exports: WebAssembly.Exports; + private memoryView: DataView; private readonly ptyWriters = new Map void>(); private nextPtyWriterId = 1; private writePtyFunctionIndex = 0; @@ -34,6 +35,7 @@ export class GhosttyRuntime { throw new Error("libghostty-vt did not export WebAssembly memory"); } this.memory = memory; + this.memoryView = new DataView(memory.buffer); const jsonPointer = this.call("ghostty_type_json"); const bytes = new Uint8Array(memory.buffer); let end = jsonPointer; @@ -104,7 +106,7 @@ export class GhosttyRuntime { } readPointer(slot: number): number { - return new DataView(this.memory.buffer).getUint32(slot, true); + return this.currentMemoryView().getUint32(slot, true); } attachPtyWriter(terminal: number, writer: (data: string) => void): number { @@ -132,27 +134,36 @@ export class GhosttyRuntime { return new Uint8Array(this.memory.buffer, pointer, size); } + /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */ + private currentMemoryView(): DataView { + if (this.memoryView.buffer !== this.memory.buffer) { + this.memoryView = new DataView(this.memory.buffer); + } + return this.memoryView; + } + setField(pointer: number, structName: string, fieldName: string, value: number): void { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.view(pointer + field.offset, field.size); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; switch (field.type) { case "bool": case "u8": - view.setUint8(0, value); + view.setUint8(offset, value); return; case "u16": - view.setUint16(0, value, true); + view.setUint16(offset, value, true); return; case "i32": - view.setInt32(0, value, true); + view.setInt32(offset, value, true); return; case "u32": case "enum": - view.setUint32(0, value, true); + view.setUint32(offset, value, true); return; case "u64": - view.setBigUint64(0, BigInt(value), true); + view.setBigUint64(offset, BigInt(value), true); return; default: throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); @@ -162,20 +173,21 @@ export class GhosttyRuntime { readField(pointer: number, structName: string, fieldName: string): number { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.view(pointer + field.offset, field.size); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; switch (field.type) { case "bool": case "u8": - return view.getUint8(0); + return view.getUint8(offset); case "u16": - return view.getUint16(0, true); + return view.getUint16(offset, true); case "i32": - return view.getInt32(0, true); + return view.getInt32(offset, true); case "u32": case "enum": - return view.getUint32(0, true); + return view.getUint32(offset, true); case "u64": - return Number(view.getBigUint64(0, true)); + return Number(view.getBigUint64(offset, true)); default: throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); }