diff --git a/README.md b/README.md index b473508571a2e..be23ad5202c26 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ pnpm build ``` After dependencies are installed, license-notice generation uses the checked-in SPDX texts in -`licenses/spdx`; builds do not need access to `raw.githubusercontent.com`. Maintainers adding a new -license can run `pnpm licenses:sync` with network access and commit the downloaded source text. +`third-party-licenses/spdx`; builds do not need access to `raw.githubusercontent.com`. Maintainers +adding a new license can run `pnpm licenses:sync` with network access and commit the downloaded +source text. Contributor and agent rules live in [AGENTS.md](./AGENTS.md). diff --git a/apps/server/package.json b/apps/server/package.json index 8f1c2717e1af7..c8d4062d60eb3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "type": "module", "scripts": { @@ -19,6 +19,7 @@ "@t3tools/source-control-definitions": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "character-entities": "2.0.2", + "diff": "8.0.3", "effect": "catalog:", "node-pty": "1.2.0-beta.15", "saxes": "6.0.0", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 721826383df8f..6cf695e948550 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -38,6 +38,7 @@ const omitInitializeModelState = process.env.T3_ACP_OMIT_INITIALIZE_MODEL_STATE const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; +const emitTrackedToolCallKindThenHang = process.env.T3_ACP_EMIT_TRACKED_TOOL_CALL_KIND_THEN_HANG; const clientFsProbePath = process.env.T3_ACP_CLIENT_FS_PROBE_PATH; const clientFsProbeLogPath = process.env.T3_ACP_CLIENT_FS_PROBE_LOG_PATH; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -1294,6 +1295,21 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + if (emitTrackedToolCallKindThenHang !== undefined) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tracked-tool-call", + title: "git status", + kind: emitTrackedToolCallKindThenHang as AcpSchema.ToolKind, + status: "pending", + rawInput: { command: "git status" }, + }, + }); + return yield* Effect.never; + } + if (hangPromptForever || (emitEmptySuccessfulBashThenHang && promptCount === 2)) { return yield* Effect.never; } diff --git a/apps/server/src/coderEnvironment.ts b/apps/server/src/coderEnvironment.ts index 6cdbd68af2ff9..fc3a6ee2af5ab 100644 --- a/apps/server/src/coderEnvironment.ts +++ b/apps/server/src/coderEnvironment.ts @@ -72,6 +72,7 @@ export const CODER_ENVIRONMENT_CAPABILITIES = { threadStop: true, environmentIcon: true, projectCloneTracking: true, + threadProjectFromScratch: true, // Links are the gateway's workspace link rules; calls between workspaces go through its hub. peerLinks: true, linkFence: true, @@ -89,6 +90,7 @@ export const CODER_OMITTED_CAPABILITIES = { serverUpdateThreadContinuation: "No server self-update.", agentActivityPublishing: "No relay, push notifications, or Live Activities.", desktopAppUpdate: "No desktop app.", + desktopAppChannelSwitch: "No desktop app.", serverBrowser: "No browser preview.", databaseStorage: "No `t3 doctor` in the workspace helper; it compacts and reclaims space itself.", ignoredFileListing: diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 56a7c41111818..ce3b4d1a5f677 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,3 +1,5 @@ +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; @@ -69,6 +71,10 @@ type FakeChangeRequest = Omit }; interface FakeProviderScenario { + prListGate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; /** The kind the fake host reports itself as. */ kind?: string; /** Answers to successive change request listings, whatever their head selector. */ @@ -454,6 +460,13 @@ function createFakeSourceControlProvider(scenario: FakeProviderScenario = {}): { [] ).map(toChangeRequest), ), + Effect.tap(() => + scenario.prListGate + ? Deferred.succeed(scenario.prListGate.started, undefined).pipe( + Effect.andThen(Deferred.await(scenario.prListGate.release)), + ) + : Effect.void, + ), ), createChangeRequest: (input) => record(input.cwd, { @@ -725,6 +738,108 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( ); it.layer(layerGitManagerTest)("GitManager", (it) => { + it.effect( + "publishes local Git changes during a held PR lookup without caching its old branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-refresh-status-gh-gate-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "release"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "release"]); + yield* runGit(repoDir, ["tag", "release"]); + const oldPr = { + number: 613, + title: "Release PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/613", + baseRefName: "main", + headRefName: "release", + }; + const newPr = { ...oldPr, number: 614, headRefName: "heads/release" }; + const providerScenario: FakeProviderScenario = { + prListSequence: [oldPr, oldPr, newPr].map((pr) => [pr]), + }; + const { manager } = yield* makeManager({ providerScenario }); + const context = yield* Layer.build( + VcsStatusBroadcaster.layer.pipe( + Layer.provide( + GitWorkflowService.layer.pipe( + Layer.provide(Layer.succeed(GitManager.GitManager, manager)), + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), + Layer.provide(VcsProcess.layer), + ), + ), + ), + ), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + }), + ), + ), + ); + const broadcaster = Context.get(context, VcsStatusBroadcaster.VcsStatusBroadcaster); + const initial = yield* broadcaster.getStatus({ cwd: repoDir }); + expect(initial.refName).toBe("release"); + expect(initial.pr?.number).toBe(613); + + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: repoDir, includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated" && event.local.hasWorkingTreeChanges) { + return Deferred.succeed(published, undefined); + } + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + const gate = { + started: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + providerScenario.prListGate = gate; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(NodePath.join(repoDir, "README.md"), "changed before refresh\n"); + const completed = yield* Deferred.make(); + const refresh = yield* broadcaster + .refreshStatus(repoDir) + .pipe(Effect.ensuring(Deferred.succeed(completed, undefined)), Effect.forkScoped); + yield* Deferred.await(gate.started); + const pending = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: repoDir, includeRemote: false })), + ); + expect(pending._tag).toBe("snapshot"); + if (pending._tag === "snapshot") expect(pending.local.hasWorkingTreeChanges).toBe(true); + yield* Deferred.await(published); + expect(yield* Deferred.isDone(completed)).toBe(false); + + yield* runGit(repoDir, ["checkout", "-b", "heads/release"]); + yield* runGit(repoDir, ["push", "-u", "origin", "refs/heads/heads/release"]); + yield* Deferred.succeed(gate.release, undefined); + const refreshed = yield* Fiber.join(refresh); + expect(refreshed.refName).toBe("heads/release"); + expect(refreshed.hasWorkingTreeChanges).toBe(true); + expect(refreshed.pr).toBeNull(); + const snapshot = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: repoDir, includeRemote: false })), + ); + expect(snapshot._tag).toBe("snapshot"); + if (snapshot._tag === "snapshot") expect(snapshot.remote).toBeNull(); + + delete providerScenario.prListGate; + const recovered = yield* broadcaster.getStatus({ cwd: repoDir }); + expect(recovered.refName).toBe("heads/release"); + expect(recovered.pr?.number).toBe(614); + expect((yield* broadcaster.getStatus({ cwd: repoDir })).pr?.number).toBe(614); + }), + ); + it.effect("status reports the listed PR whose head is the branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c9a2d809b3f56..6ee2899863063 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -53,6 +53,7 @@ export class GitWorkflowService extends Context.Service< readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect; + readonly currentBranch: (cwd: string) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, options?: GitManager.GitRemoteStatusOptions, @@ -340,6 +341,32 @@ export const make = Effect.gen(function* () { ), ); }), + currentBranch: Effect.fn("GitWorkflowService.currentBranch")(function* (cwd) { + if (!(yield* detectGitRepositoryForStatus("GitWorkflowService.currentBranch", cwd))) { + return null; + } + const result = yield* git.execute({ + operation: "GitWorkflowService.currentBranch", + cwd, + args: ["symbolic-ref", "--quiet", "HEAD"], + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + if (result.exitCode === 0) { + return result.stdout.trim().replace(/^refs\/heads\//, "") || null; + } + if (result.exitCode === 1) return null; + return yield* new GitCommandError({ + operation: "GitWorkflowService.currentBranch", + command: "git symbolic-ref", + cwd, + argumentCount: 3, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + detail: "Failed to read the checked-out branch.", + }); + }), remoteStatus: Effect.fn("GitWorkflowService.remoteStatus")(function (input, options) { return detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index c64c5a6b8824f..7791134d7cf89 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -12,6 +12,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -47,6 +48,8 @@ export interface McpClientCaller { * `clientModeCeiling`). */ readonly narrowedTo?: ClientModes | undefined; + /** The origin an outside client reached; its thread links open the web app there. */ + readonly webOrigin?: string | undefined; } export interface ClientModes { @@ -100,6 +103,20 @@ export interface McpInvocationScope { readonly linkedEnvironments?: boolean; } +/** + * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so + * they get a web URL on the origin they reached instead. + */ +export const threadLinkFor = ( + scope: Pick, + threadId: ThreadId, +): string => { + const origin = scope.client?.webOrigin; + return origin === undefined + ? formatThreadLink({ environmentId: scope.environmentId, threadId }) + : `${origin}/${encodeURIComponent(scope.environmentId)}/${encodeURIComponent(threadId)}`; +}; + export class McpInvocationContext extends Context.Service< McpInvocationContext, McpInvocationScope diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 156b52cfef1aa..87810e225d89a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1432,6 +1432,16 @@ describe("OrchestratorMcpService provider resolution", () => { models: ["oc/m12", "oc/m01", "oc/m03", "oc/m04", "oc/m05", "oc/m06", "oc/m07", "oc/m08"], omitted: 3, }); + // A word naming the provider matches all its models, so legacy ones stay out. + assert.deepEqual(listing(yield* capabilitiesWith("Codex")), [ + { id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 }, + { id: opencodeInstanceId, models: [], omitted: 0 }, + ]); + assert.deepEqual(listing(yield* capabilitiesWith("codex gpt 5"))[0], { + id: codexInstanceId, + models: ["gpt-5"], + omitted: 0, + }); }), ); @@ -2695,6 +2705,71 @@ describe("OrchestratorMcpService thread lookup", () => { ); }); +describe("OrchestratorMcpService thread list", () => { + const client: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:thread-list"), + requestNamespace: "client:thread-list", + thread: undefined, + client: { sessionId: "thread-list", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const shell = (id: string, projectId: string, snoozed: boolean) => ({ + ...liveThreadShell(ThreadId.make(id)), + projectId: ProjectId.make(projectId), + status: "completed" as const, + snoozedUntil: snoozed ? DateTime.makeUnsafe("2099-01-01T00:00:00Z") : null, + }); + const shells = [ + shell("thread:a-snoozed", "project:a", true), + shell("thread:a-awake", "project:a", false), + shell("thread:b-snoozed", "project:b", true), + ]; + const service = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + listProjectThreads: (input) => + Effect.succeed( + shells.filter( + (thread) => input.projectId === undefined || thread.projectId === input.projectId, + ), + ), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ); + + it.effect("lists snoozed threads across every project with allProjects", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const listed = yield* mcp.listThreads(client, { + allProjects: true, + projectId: ProjectId.make("project:a"), + snoozed: true, + }); + assert.equal(listed.projectId, null); + assert.deepStrictEqual( + listed.threads.map((thread) => [thread.threadId, thread.projectId]), + [ + ["thread:a-snoozed", "project:a"], + ["thread:b-snoozed", "project:b"], + ], + ); + }).pipe(Effect.provide(service)), + ); +}); + describe("OrchestratorMcpService thread stop", () => { const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 4f3f8aaaded6e..8f7aa29b03e04 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,6 +1,5 @@ import { CommandId, - type EnvironmentId, type RunId, isProviderAvailable, MessageId, @@ -108,6 +107,7 @@ import { type McpInvocationScope, type McpThreadInvocationScope, requireThreadScope, + threadLinkFor, } from "./McpInvocationContext.ts"; import { interactionModeRank, @@ -286,9 +286,9 @@ const FULL_CATALOG_DRIVERS: ReadonlySet = new Set(["codex", "claudeAgent const LISTED_MODEL_LIMIT = 8; /** - * Picks the models orchestrator_capabilities shows for one provider. Hidden - * models never appear. With search terms, lists matches; otherwise lists pinned - * models (custom, favorite, the caller's own) plus the provider's non-legacy + * Picks the models orchestrator_capabilities shows for one provider. Hidden models never + * appear. Search words naming the provider are dropped; other words list matches. With no + * words left, lists pinned models (custom, favorite, the caller's own) plus non-legacy * models, or only the first few in the user's model order for large catalogs. */ function listCapabilityModels(input: { @@ -303,10 +303,13 @@ function listCapabilityModels(input: { const visible = input.provider.models .filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug)) .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); - if (input.searchTerms.length > 0) { + const { instanceId, driver, displayName } = input.provider; + const providerText = `${instanceId} ${driver} ${displayName ?? ""}`.toLowerCase(); + const modelTerms = input.searchTerms.filter((term) => !providerText.includes(term)); + if (modelTerms.length > 0) { const matches = visible.filter((model) => { const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); - return input.searchTerms.every((term) => text.includes(term)); + return modelTerms.every((term) => text.includes(term)); }); const listed = matches.slice(0, LISTED_MODEL_LIMIT); return { listed, omitted: matches.length - listed.length }; @@ -748,14 +751,15 @@ function threadPullRequests( } function listItemFromShell( - environmentId: EnvironmentId, + scope: McpInvocationScope, shell: OrchestrationV2ThreadShell, nowMs: number, ): OrchestratorMcpThreadListItem { return { threadId: shell.id, + projectId: shell.projectId, title: shell.title, - link: formatThreadLink({ environmentId, threadId: shell.id }), + link: threadLinkFor(scope, shell.id), createdBy: shell.createdBy, creationSource: shell.creationSource, status: shell.activityRunStatus ?? shell.status, @@ -777,7 +781,7 @@ function listItemFromShell( } function threadDetail( - environmentId: EnvironmentId, + scope: McpInvocationScope, projection: Pick, itemCount: number, shell: OrchestrationV2ThreadShell, @@ -789,7 +793,7 @@ function threadDetail( threadId: projection.thread.id, projectId: projection.thread.projectId, title: projection.thread.title, - link: formatThreadLink({ environmentId, threadId: projection.thread.id }), + link: threadLinkFor(scope, projection.thread.id), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, status: active?.status ?? latest?.status ?? "idle", @@ -2763,10 +2767,12 @@ const make = Effect.gen(function* () { ), listThreads: Effect.fn("OrchestratorMcpService.listThreads")(function* (scope, input) { const { parent } = yield* loadCaller(scope); - const projectId = yield* resolveProjectTarget(parent, input.projectId); + const projectId = input.allProjects + ? null + : yield* resolveProjectTarget(parent, input.projectId); const projectThreads = yield* threadManagement .listProjectThreads({ - projectId, + projectId: projectId ?? undefined, includeSubagents: input.includeSubagents !== false, }) .pipe( @@ -2799,7 +2805,7 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => listItemFromShell(scope.environmentId, shell, nowMs)), + threads: page.map((shell) => listItemFromShell(scope, shell, nowMs)), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; @@ -2880,7 +2886,7 @@ const make = Effect.gen(function* () { } return { thread: threadDetail( - scope.environmentId, + scope, target, timeline.totalItems, shell, @@ -2944,7 +2950,7 @@ const make = Effect.gen(function* () { ); return { threadId: input.threadId, - link: formatThreadLink({ environmentId: scope.environmentId, threadId: input.threadId }), + link: threadLinkFor(scope, input.threadId), messageId, runId: result.run.id, ...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 0b1cf59685f00..12c1ba5a3836d 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -210,7 +210,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List threads in a project, newest first. Omit `projectId` for yours. Filter by status, title, settled, or snoozed. Page with the cursor.", + "List threads newest first, in one project or with `allProjects`. Page with `cursor`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index d0ea6437cd1c3..729b4ee207d06 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -42,6 +42,7 @@ const clientLaunchHarness = (input: { readonly launched: Array; readonly workspaceRoot?: string; readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; + readonly webOrigin?: string; }) => { const projectId = ProjectId.make("project:client-target"); const modelSelection = { @@ -75,6 +76,7 @@ const clientLaunchHarness = (input: { sessionId: "session-1", label: "Claude Code", access: input.runtimeModeCeiling, + webOrigin: input.webOrigin, }, issuedAt: 0, capabilities: new Set(["orchestration" as const]), @@ -154,15 +156,17 @@ it.effect( readonly runtimeMode?: RuntimeMode; readonly targeted?: false; readonly overrideModel?: ModelSelection; + readonly webOrigin?: string; }, ) { - const { targeted, overrideModel, ...input } = params; + const { targeted, overrideModel, webOrigin, ...input } = params; const launched: Array = []; const { projectId, modelSelection, dependencies } = clientLaunchHarness({ runtimeModeCeiling, defaultRuntimeMode, launched, ...(overrideModel === undefined ? {} : { overrideModel }), + ...(webOrigin === undefined ? {} : { webOrigin }), }); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( @@ -189,8 +193,14 @@ it.effect( expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); - const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix" }); + const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { + title: "Fix", + webOrigin: "https://box.example.ts.net", + }); expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits"); + expect(aboveCeiling.result).toMatchObject({ + link: `https://box.example.ts.net/environment/${encodeURIComponent(aboveCeiling.launched[0]?.threadId ?? "")}`, + }); const requested = yield* launchWith("full-access", "approval-required", { title: "Fix", diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 950cb38c6c4fd..34a8b8c1f4bb6 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -19,7 +19,6 @@ import * as Repositories from "../../../sourceControl/SourceControlRepositorySer import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -181,6 +180,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { input.modelSelection ?? launchedModel ?? caller?.modelSelection ?? + // Coder: no out-of-usage fallback (#26164); it reads plan-window usage, not carried. projectDefaults?.defaultModelSelection ?? undefined; if (modelSelection === undefined) @@ -240,7 +240,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, - link: formatThreadLink({ environmentId: scope.environmentId, threadId: thread.id }), + link: McpInvocationContext.threadLinkFor(scope, thread.id), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 8ad9afa435193..b1d34c91be8af 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -104,11 +103,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { const { task, threadId } = yield* scheduler .runNow({ id: input.taskId }) .pipe(Effect.mapError(unavailable)); - const { environmentId } = yield* McpInvocationContext.McpInvocationContext; + const scope = yield* McpInvocationContext.McpInvocationContext; return { taskId: task.id, threadId, - ...(threadId === null ? {} : { link: formatThreadLink({ environmentId, threadId }) }), + ...(threadId === null + ? {} + : { + link: McpInvocationContext.threadLinkFor(scope, threadId), + }), lastRunStatus: task.lastRunStatus, runCount: task.runCount, nextRunAt: task.nextRunAt, @@ -118,7 +121,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { t3_thread_search: McpToolAccess.reads((input) => Effect.gen(function* () { const { caller } = yield* readCaller(); - const { environmentId } = yield* McpInvocationContext.McpInvocationContext; + const scope = yield* McpInvocationContext.McpInvocationContext; const threadSearch = yield* ThreadSearch.ThreadSearch; const { matches } = yield* threadSearch .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) @@ -126,7 +129,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { return { matches: matches.map((match) => ({ ...match, - link: formatThreadLink({ environmentId, threadId: match.threadId }), + link: McpInvocationContext.threadLinkFor(scope, match.threadId), })), }; }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b600cd8c86217..e3de12f78eb0c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -797,8 +797,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assert.isFunction(canUseTool); const requestEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, Effect.forkScoped, ); const command = { command: "node -e 'console.log(42)'" }; @@ -807,6 +810,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { signal: new AbortController().signal, toolUseID: "tool-bash-accept-edits", requestId: "request-bash-accept-edits", + description: "Check the Node runtime", }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -819,12 +823,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; - const event = first.event; - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - assert.equal(event.value.runtimeRequest.kind, "command"); + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, + requestId: event!.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); @@ -8608,6 +8616,152 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { + case: "an Edit", + toolName: "Edit", + toolInput: { + file_path: "/workspace/src/math.ts", + old_string: " return a - b;", + new_string: " return a + b;", + }, + toolUseResult: { + filePath: "/workspace/src/math.ts", + oldString: " return a - b;", + newString: " return a + b;", + originalFile: "export function add(a: number, b: number) {\n return a - b;\n}\n", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [ + " export function add(a: number, b: number) {", + "- return a - b;", + "+ return a + b;", + " }", + ], + }, + ], + userModified: false, + replaceAll: false, + }, + diffStr: + "--- /workspace/src/math.ts\n+++ /workspace/src/math.ts\n@@ -1,3 +1,3 @@\n export function add(a: number, b: number) {\n- return a - b;\n+ return a + b;\n }\n", + }, + { + case: "a Write that creates a file", + toolName: "Write", + toolInput: { file_path: "/workspace/notes.md", content: "# Notes\n\nShip it.\n" }, + toolUseResult: { + type: "create", + filePath: "/workspace/notes.md", + content: "# Notes\n\nShip it.\n", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: "--- /dev/null\n+++ /workspace/notes.md\n@@ -0,0 +1,3 @@\n+# Notes\n+\n+Ship it.\n", + }, + { + case: "a Write that creates an empty file", + toolName: "Write", + toolInput: { file_path: "/workspace/.keep", content: "" }, + toolUseResult: { + type: "create", + filePath: "/workspace/.keep", + content: "", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: undefined, + }, + ])("stores the diff panel patch for $case", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const toolUseId = `toolu_01${testCase.toolName}Patch`; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-${testCase.toolName}-patch`), + text: "Change it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${testCase.toolName}_patch`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: testCase.toolName, + input: testCase.toolInput, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000793", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `The file ${testCase.toolInput.file_path} has been updated successfully.`, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000794", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: testCase.toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000795", result: "Done." }), + ); + yield* harness.awaitTerminal(1); + + const fileChange = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolUseId, + ); + assert.deepEqual( + fileChange?.type === "turn_item.updated" && fileChange.turnItem.type === "file_change" + ? { diffStr: fileChange.turnItem.diffStr } + : undefined, + { diffStr: testCase.diffStr }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => Effect.scoped( Effect.gen(function* () { @@ -12631,7 +12785,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* fileSystem.makeDirectory(transcriptDir, { recursive: true }); yield* fileSystem.writeFileString( NodePath.join(transcriptDir, "agent-a1.jsonl"), - `{"type":"assistant","uuid":"wf-line-1-${options?.nativeSessionId ?? "default"}","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n`, + `{"type":"assistant","effort":"xhigh","uuid":"wf-line-1-${options?.nativeSessionId ?? "default"}","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a1","usage":{"speed":"standard"},"content":[{"type":"text","text":"A1 from the transcript"}]}}\n`, ); const harness = yield* makeWakeHarnessWithOptions({ ...options, @@ -13890,6 +14044,97 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("clears workflow member metadata only after a successful transcript read", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-metadata"), + text: "Run the alpha workflow in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "done" }), + ); + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] + ?.effort === "xhigh", + ); + let coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; + assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); + + const transcriptPath = NodePath.join(harness.transcriptDir, "agent-a1.jsonl"); + NodeFS.renameSync(transcriptPath, `${transcriptPath}.saved`); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001041", + state: "done", + label: "alpha:unreadable", + }), + ); + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] + ?.label === "alpha:unreadable", + ); + coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; + assert.equal(coordinator.workflow?.agents[0]?.effort, "xhigh"); + assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); + + NodeFS.renameSync(`${transcriptPath}.saved`, transcriptPath); + NodeFS.appendFileSync( + transcriptPath, + '{"type":"assistant","uuid":"wf-metadata-cleared","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_new","content":[{"type":"text","text":"New answer without metadata"}]}}\n', + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001042", + state: "done", + label: "alpha:new-response", + }), + ); + yield* harness.until(() => { + const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow + ?.agents[0]; + return ( + member?.label === "alpha:new-response" && + member.effort === undefined && + member.speed === undefined + ); + }); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001043", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow + ?.agents[0]; + assert.isUndefined(member?.effort); + assert.isUndefined(member?.speed); + const childThreadId = member?.childThreadId; + assert.deepEqual( + threadMessages(harness.events, childThreadId) + .filter((event) => event.message.role === "assistant") + .map((event) => event.message.text), + ["A1 from the transcript", "New answer without metadata"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => Effect.scoped( Effect.gen(function* () { @@ -13954,11 +14199,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 2); - const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] + ?.effort === "xhigh", + ); + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; assert.deepEqual( - coordinator?.workflow?.agents.map((member) => member.state), + coordinator.workflow?.agents.map((member) => member.state), ["completed", "completed"], ); + assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); + assert.isUndefined(coordinator.workflow?.agents[1]?.effort); + assert.isUndefined(coordinator.workflow?.agents[1]?.speed); assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed"); assert.deepEqual( @@ -14034,6 +14287,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { completedMember?.completedAt && DateTime.toEpochMillis(completedMember.completedAt), 4432, ); + assert.isUndefined( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.effort, + ); + assert.isUndefined( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.speed, + ); assert.notDeepEqual( workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt, previousStartedAt, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 8c2786d2182c0..eac77647697c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7,6 +7,7 @@ import { formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; import { field, text } from "../unknownField.ts"; +import { mergeWorkflowAgentMetadata, type WorkflowAgentMetadata } from "../workflowAgentAnswers.ts"; import { initialWorkflowAgentTranscriptCursor, readWorkflowAgentTranscript, @@ -102,6 +103,7 @@ import { resolveIdleCompaction, } from "@t3tools/contracts"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -1488,6 +1490,7 @@ export function makeClaudeQueryOptions(input: { ...launchAppendSystemPrompt, ].join("\n\n"), }, + // Coder: no session holds device tools, so no SubagentStart device hook (#25661). ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; const additionalDirectories = [ @@ -2842,6 +2845,44 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } +const decodeClaudeFileEditResult = Schema.decodeUnknownOption( + Schema.Struct({ + type: Schema.optional(Schema.String), + filePath: Schema.String, + content: Schema.optional(Schema.String), + structuredPatch: Schema.mutable( + Schema.Array( + Schema.Struct({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.mutable(Schema.Array(Schema.String)), + }), + ), + ), + }), +); + +function claudeFileChangeText(output: ClaudeNativeToolOutput): string { + const result = + output.type === "structured_tool_use_result" + ? Option.getOrUndefined(decodeClaudeFileEditResult(output.value)) + : undefined; + if (result === undefined) return claudeNativeToolOutputText(output); + const patch = + result.structuredPatch.length === 0 && result.type === "create" && result.content !== undefined + ? structuredPatch("/dev/null", result.filePath, "", result.content) + : { + oldFileName: result.filePath, + newFileName: result.filePath, + oldHeader: undefined, + newHeader: undefined, + hunks: result.structuredPatch, + }; + return patch.hunks.length === 0 ? "" : formatPatch(patch, FILE_HEADERS_ONLY); +} + function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); if (typeof value === "object" && value !== null) { @@ -4429,6 +4470,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly finalTranscriptRead: boolean; readonly agentId: string | undefined; readonly transcriptCursor: WorkflowAgentTranscriptCursor; + readonly transcriptMetadata: WorkflowAgentMetadata; readonly nextItemOrdinal: number; readonly toolCalls: Map; readonly textShown: boolean; @@ -5384,7 +5426,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const outputText = itemType === "command_execution" ? claudeCommandOutputText(input.output) - : claudeNativeToolOutputText(input.output); + : itemType === "file_change" + ? claudeFileChangeText(input.output) + : claudeNativeToolOutputText(input.output); const turnItem: OrchestrationV2TurnItem = itemType === "command_execution" ? { @@ -5614,8 +5658,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const now = yield* DateTime.now; const seen = yield* Ref.get(workflowMemberStates); const transcriptDir = input.workflow.runHandles?.transcriptDir; + const membersWithMetadata = input.workflow.agents.slice(); - for (const member of input.workflow.agents) { + for (const [memberIndex, member] of input.workflow.agents.entries()) { if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) return; const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`; @@ -5801,6 +5846,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( previous === undefined || restarted || previous.agentId !== agentId ? initialWorkflowAgentTranscriptCursor : previous.transcriptCursor; + let transcriptMetadata = + previous === undefined || restarted || previous.agentId !== agentId + ? {} + : previous.transcriptMetadata; let textShown = !restarted && previous?.textShown === true; let resultShown = !restarted && previous?.resultShown === true; let turnEnded = !restarted && previous?.turnEnded === true; @@ -5845,6 +5894,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( transcriptRead = true; const advanced = read.value.cursor.offset !== transcriptCursor.offset; transcriptCursor = read.value.cursor; + transcriptMetadata = mergeWorkflowAgentMetadata( + read.value.lines, + transcriptMetadata, + ); for (const line of read.value.lines) { if (yield* projectSubagentTranscriptLine(target, line)) textShown = true; turnEnded = isClaudeTranscriptTurnEnd(line); @@ -5867,6 +5920,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (!(yield* drain())) return; } } + if ( + transcriptRead && + (transcriptMetadata.effort !== member.effort || + transcriptMetadata.speed !== member.speed) + ) { + membersWithMetadata[memberIndex] = { + ...member, + effort: transcriptMetadata.effort, + speed: transcriptMetadata.speed, + }; + } if (settled) { // Open calls receive no results after their member settles. yield* endOpenToolCalls(); @@ -5909,6 +5973,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( textShown && input.coordinator.task.status !== "running"), transcriptCursor, + transcriptMetadata, nextItemOrdinal, toolCalls, textShown, @@ -5920,6 +5985,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ); } + if ( + membersWithMetadata.some((member, index) => member !== input.workflow.agents[index]) + ) { + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) + return; + input.coordinator.task = { + ...input.coordinator.task, + workflow: { ...input.workflow, agents: membersWithMetadata }, + }; + yield* emitProviderEvent({ + type: "subagent.updated", + driver: CLAUDE_PROVIDER, + subagent: input.coordinator.task, + }); + } }); const confirmedSubagentOutcome = (task: OrchestrationV2Subagent | undefined) => { @@ -9401,7 +9481,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); + // The user approves the command itself; Claude's title and description only summarize it. + const command = inputRecordValue(nativeToolInput, "command"); const prompt = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? callbackOptions.title ?? callbackOptions.description ?? callbackOptions.decisionReason ?? @@ -9600,8 +9683,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId: string, ) { const queryPolicy = yield* queryPolicyForTurn(turnInput); + const mcpSession = yield* mcpSessions.read(turnInput.threadId); const mcpOverrides = claudeMcpQueryOverrides({ - mcpSession: yield* mcpSessions.read(turnInput.threadId), + mcpSession, readOnlySandbox: sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly", ...(queryPolicy.allowedTools === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts index baea36ca738da..360a21c068f25 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts @@ -128,7 +128,10 @@ describe("mergeClaudeWorkflowProgress", () => { expect(first?.agents[0]?.completedAt).toBe(2010); expect(first?.agents[1]).not.toHaveProperty("completedAt"); const second = mergeClaudeWorkflowProgress({ - previous: first, + previous: first && { + ...first, + agents: first.agents.map((member) => ({ ...member, effort: "max", speed: "standard" })), + }, message: snapshotFrame([ { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 }, ]), @@ -142,7 +145,7 @@ describe("mergeClaudeWorkflowProgress", () => { phaseIndex: 1, phaseTitle: "Alpha", }, - first?.agents[1], + { ...first?.agents[1], effort: "max", speed: "standard" }, ]); expect(second?.phases).toEqual(first?.phases); }); @@ -179,6 +182,37 @@ describe("mergeClaudeWorkflowProgress", () => { expect(second?.agents).toEqual(first?.agents); }); + it("retains member fields omitted by a later frame in the same attempt", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + agent({ + attempt: 2, + model: "claude-opus-5[1m]", + promptPreview: "Current prompt", + tokens: 100, + }), + ]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first && { + ...first, + agents: first.agents.map((member) => ({ ...member, effort: "xhigh", speed: "standard" })), + }, + message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]), + }); + expect(second?.agents[0]).toMatchObject({ + state: "completed", + attempt: 2, + prompt: "Current prompt", + result: "Current result", + totalTokens: 100, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", + }); + }); + it("drops malformed entries instead of failing the frame", () => { const workflow = mergeClaudeWorkflowProgress({ previous: undefined, diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 7f19a531b26f5..aa63eec35a21b 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -164,6 +164,53 @@ it.each([ ); }); +// regression: #26695 an interrupt-restart steer cancels Copilot's background agents +it("queues a steer for a provider that cannot steer either way", () => { + const projection = dispatchProjection( + capabilities((current) => ({ + ...current, + turns: { + ...current.turns, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + }, + })), + ); + for (const deliveryIntent of ["auto", "steer"] as const) { + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + deliveryIntent, + ), + { type: "queue_after_active" }, + ); + } + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: activeRunId, + }), + { type: "queue_after_active" }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "restart", + ), + { type: "restart_active", targetRunId: activeRunId }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: RunId.make("command-policy-missing-run"), + }), + { type: "steer_active", targetRunId: RunId.make("command-policy-missing-run") }, + ); +}); + it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => { const projection = dispatchProjection(baseCapabilities); const maintenance = { diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 302bb12f797cb..869851790177a 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -99,11 +99,47 @@ export function resolveMessageDispatchIntent( // Compaction and sign-out cannot accept steers; queue follow-ups. const target = projection.runs.find((run) => run.id === mode.targetRunId); const targetMessage = projection.messages.find((message) => message.id === target?.userMessageId); - return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage) + if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { + return { type: "queue_after_active" }; + } + // A provider that can neither steer nor restart its turn takes the message after the turn. + const turns = + target === undefined || !isActiveRun(target) + ? undefined + : providerTurnCapabilities(projection, target); + return turns !== undefined && + !turns.supportsActiveSteering && + !(turns.supportsInterrupt && turns.supportsSteeringByInterruptRestart) && + turns.supportsQueuedMessages ? { type: "queue_after_active" } : mode; } +function isActiveRun(run: OrchestrationV2Run): boolean { + return ( + run.status === "preparing" || + run.status === "starting" || + run.status === "running" || + run.status === "waiting" + ); +} + +function providerTurnCapabilities( + projection: OrchestrationV2ThreadProjection, + run: OrchestrationV2Run, +) { + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + const providerSession = + providerThread?.providerSessionId == null + ? undefined + : projection.providerSessions.find( + (candidate) => candidate.id === providerThread.providerSessionId, + ); + return providerSession?.capabilities.turns; +} + function resolveDeliveryIntent( projection: OrchestrationV2ThreadProjection, requestedMode: MessageDispatchMode, @@ -111,13 +147,7 @@ function resolveDeliveryIntent( ): MessageDispatchMode { if (deliveryIntent === undefined) return requestedMode; - const activeRun = projection.runs.findLast( - (run) => - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting", - ); + const activeRun = projection.runs.findLast(isActiveRun); if (activeRun === undefined) return { type: "start_immediately" }; if (deliveryIntent === "steer") { return { type: "steer_active", targetRunId: activeRun.id }; @@ -137,16 +167,7 @@ function resolveDeliveryIntent( return { type: "queue_after_active" }; } - const providerThread = projection.providerThreads.find( - (candidate) => candidate.id === activeRun.providerThreadId, - ); - const providerSession = - providerThread?.providerSessionId == null - ? undefined - : projection.providerSessions.find( - (candidate) => candidate.id === providerThread.providerSessionId, - ); - const capabilities = providerSession?.capabilities.turns; + const capabilities = providerTurnCapabilities(projection, activeRun); if (capabilities?.supportsActiveSteering === true) { return { type: "steer_active", targetRunId: activeRun.id }; } diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 2334d0468f4bf..9e3050206b546 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -454,6 +454,23 @@ function wakeWorkStartedAt( const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); +/** Unfinished runs (including queued) or open requests block moving a thread to another project. */ +export function threadHasUnfinishedWork(records: { + readonly runs: ReadonlyArray<{ readonly status: string }>; + readonly runtimeRequests: ReadonlyArray<{ readonly status: string }>; +}): boolean { + return ( + records.runs.some( + (run) => + run.status !== "completed" && + run.status !== "failed" && + run.status !== "cancelled" && + run.status !== "interrupted" && + run.status !== "rolled_back", + ) || records.runtimeRequests.some((request) => request.status === "pending") + ); +} + function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { switch (command.type) { case "thread.create": @@ -485,6 +502,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request-watch.sync": case "thread.pull-request.sync": case "thread.title.regeneration.complete": + case "thread.project.adopt-folder": case "thread.runtime-mode.set": case "thread.interaction-mode.set": case "thread.model-selection.set": @@ -3982,6 +4000,56 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + const dispatchThreadProjectAdoptFolder = Effect.fn( + "orchestrationV2.dispatch.threadProjectAdoptFolder", + )(function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.project.adopt-folder" } + >, + events: Ref.Ref>, + ) { + const reject = (cause: string) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} ${cause}`, + }); + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const thread = projection.thread; + if (thread.deletedAt !== null) return yield* reject("is deleted."); + if (threadHasUnfinishedWork(projection)) { + return yield* reject("still has work in progress. Finish or stop it first."); + } + const current = yield* projects.get(thread.projectId).pipe(mapDispatchError(command)); + const target = yield* projects.get(command.projectId).pipe(mapDispatchError(command)); + if (Option.isNone(target) || target.value.deletedAt !== null) { + return yield* reject(`cannot move to missing project ${command.projectId}.`); + } + // The thread always lands in the project root, so the move must not change its cwd. + const cwd = thread.worktreePath ?? Option.getOrUndefined(current)?.workspaceRoot; + if (cwd !== target.value.workspaceRoot) { + return yield* reject(`does not work in ${target.value.workspaceRoot}.`); + } + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, projectId: command.projectId, worktreePath: null, updatedAt: now }, + }); + }); + const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")( function* ( command: OrchestrationV2ServerCommand, @@ -12486,6 +12554,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; + case "thread.project.adopt-folder": + yield* dispatchThreadProjectAdoptFolder(command, events); + break; case "message.dispatch": { // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. const thread = yield* projectionStore diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 3fecc1c7d2d61..7f1006f1c5ec5 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -46,6 +46,9 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -68,8 +71,16 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const makeLayerTest = (ingestorEventSink: Layer.Layer) => +const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), +}); +const makeLayerTest = ( + ingestorEventSink: Layer.Layer, + instances = layerEmptyInstances, +) => Layer.mergeAll( + instances, layerTestDatabase, layerTestStores, layerTestEventSink, @@ -79,6 +90,7 @@ const makeLayerTest = (ingestorEventSink: Layer.Layer + Effect.scoped( + Effect.gen(function* () { + const refreshed = yield* Deferred.make(); + const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( + Effect.provide( + Layer.mock(ProviderAdapter.ProviderAdapterV2)({ + instanceId: ProviderInstanceId.make("cursor-work"), + driver: ProviderDriverKind.make("cursor"), + }), + ), + ); + const instance: ProviderInstance = { + instanceId: ProviderInstanceId.make("cursor-work"), + driverKind: ProviderDriverKind.make("cursor"), + continuationIdentity: { + driverKind: ProviderDriverKind.make("cursor"), + continuationKey: "work", + }, + enabled: true, + displayName: undefined, + orchestrationAdapter: adapter, + textGeneration: { + generateBranchName: () => Effect.die("unused"), + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateThreadTitle: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + }, + snapshot: { + refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), + getSnapshot: Effect.die("unused"), + refresh: Effect.die("unused"), + resolveMaintenance: () => Effect.die("unused"), + applyUsageLimits: () => Effect.die("unused"), + streamChanges: Stream.empty, + }, + }; + const lookups: ProviderInstanceId[] = []; + const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.sync(() => { + lookups.push(id); + return id === instance.instanceId ? instance : undefined; + }), + }); + yield* Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const input = { + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }; + for (const failureClass of ["validation_error", "usage_limit"] as const) { + const stored = yield* ingestor.ingestNormalized({ + ...input, + event: { + type: "turn.terminal", + driver: instance.driverKind, + providerThreadId: idAllocator.derive.providerThread({ + driver: instance.driverKind, + nativeThreadId: "limited", + }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: instance.driverKind, + nativeTurnId: failureClass, + }), + runOrdinal: 1, + failureItemOrdinal: 1, + status: "failed", + failure: makeProviderFailure({ + class: failureClass, + message: "Provider rejected turn.", + }), + threadDisposition: "reusable", + }, + }); + assert.equal(stored.length, 1); + if (failureClass === "validation_error") assert.deepEqual(lookups, []); + } + assert.deepEqual(lookups, [instance.instanceId]); + yield* Deferred.await(refreshed); + }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }), + ), +); + it.effect("records accepted billed turn usage once without billing the context window", () => { const recorded: Array>> = []; const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 368c1dd73890b..c5ec581f03bc7 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -33,6 +33,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -313,6 +314,7 @@ export const layer: Layer.Layer< | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | ThreadCommandExecutor.ThreadCommandExecutor + | ProviderInstanceRegistry.ProviderInstanceRegistry > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { @@ -323,6 +325,8 @@ export const layer: Layer.Layer< const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; + const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); const makeDomainEvent = @@ -781,6 +785,23 @@ export const layer: Layer.Layer< }, (effect, input) => effect.pipe( + Effect.tap((storedEvents) => + Effect.gen(function* () { + if ( + storedEvents.length === 0 || + input.event.type !== "turn.terminal" || + input.event.status !== "failed" || + input.event.failure.class !== "usage_limit" + ) + return; + const instance = yield* instances.getInstance(input.providerInstanceId); + if (!instance?.snapshot.refreshUsage) return; + yield* instance.snapshot.refreshUsage.pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope, { startImmediately: true }), + ); + }), + ), Effect.flatMap((storedEvents) => Effect.gen(function* () { if (input.event.type !== "app_thread.created") return storedEvents; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 49115640f0378..f9f98cbc67be5 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -48,6 +48,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -226,6 +227,7 @@ function makeThreadCreatedEvent(input: { readonly threadId: ThreadId; readonly now: DateTime.Utc; readonly projectId?: ProjectId; + readonly delegatedBy?: ThreadId; }) { return Effect.gen(function* () { const projectId = @@ -250,11 +252,14 @@ function makeThreadCreatedEvent(input: { branch: null, worktreePath: null, activeProviderThreadId: providerThreadId, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: input.threadId, - }, + lineage: + input.delegatedBy === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: input.threadId } + : { + parentThreadId: input.delegatedBy, + relationshipToParent: "subagent", + rootThreadId: input.delegatedBy, + }, forkedFrom: null, createdAt: input.now, updatedAt: input.now, @@ -530,6 +535,10 @@ function layerTest(input: { const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerConfiguredEventSink, IdAllocator.layer, layerTestStores, @@ -645,6 +654,7 @@ function runBrowserAccessScenario(input: { readonly deviceOverride?: boolean; readonly createThread?: boolean; readonly projectExists?: boolean; + readonly delegatedBy?: ThreadId; }) { return Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -673,7 +683,15 @@ function runBrowserAccessScenario(input: { }); if (input.createThread !== false) { yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now, projectId })], + events: [ + yield* makeThreadCreatedEvent({ + idAllocator, + threadId, + now, + projectId, + ...(input.delegatedBy === undefined ? {} : { delegatedBy: input.delegatedBy }), + }), + ], }); } yield* manager @@ -3649,3 +3667,16 @@ it.effect( assert.isFalse(denied?.capabilities?.has("device")); }), ); + +// Device instructions reach delegated children only because their own sessions hold `device`. +it.effect("ProviderSessionManagerV2 gives a delegated child its project's device access", () => + Effect.gen(function* () { + const child = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: false, + projectOverride: false, + deviceOverride: true, + delegatedBy: ThreadId.make("thread-provider-session-manager-parent"), + }); + assert.isTrue(child?.capabilities?.has("device")); + }), +); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 39aee1ec5a54d..4e8cd5fccb0a0 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,3 +1,5 @@ +import * as NodeV8 from "node:v8"; + import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; import { @@ -21,10 +23,12 @@ import { type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -497,6 +501,40 @@ const mergingSnapshot = () => makeSnapshot([makeThread(MERGING_THREAD, { pullRequests: [makeLink(7, { state: "open" })] })]); describe("PullRequestSyncReactor", () => { + it.effect("lets a finished sweep's links go while a cache keeps the fiber that read them", () => + Effect.gen(function* () { + class SweptLink { + readonly swept = true; + } + // Like PullRequestReadCache's revisions: a lookup fiber, kept for good, forked from the reader. + const kept = yield* Cache.makeWith(() => Effect.void, { + capacity: 1, + timeToLive: () => Duration.infinity, + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + summary: (input) => Cache.get(kept, undefined).pipe(Effect.as(makeSummary(input))), + }); + yield* Ref.set( + fixture.snapshots, + makeSnapshot( + Array.from({ length: 30 }, (_, index) => + makeThread(`thread-${index}`, { + pullRequests: [Object.assign(new SweptLink(), makeLink(7))], + }), + ), + ), + ); + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 30); + yield* Ref.set(fixture.snapshots, makeSnapshot([])); + + assert.strictEqual(NodeV8.queryObjects(SweptLink, { format: "count" }), 0); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + it.effect("retries a failed stack read after the summary becomes terminal", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index e557ecd770e1a..ab3c0dca08adc 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -392,7 +392,9 @@ export const make = Effect.gen(function* () { const stacksReadThisSweep = new Map(); const persistence = yield* Semaphore.make(1); - const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + // Effect.fn's stack frames close over these sweep-scoped bodies and their arguments, and a + // cache lookup forked inside keeps those frames, so a plain span leaves the sweep collectable. + const syncEntry = Effect.fnUntraced(function* ( entry: LinkEntry, fields: SnapshotFields, fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, @@ -446,12 +448,9 @@ export const make = Effect.gen(function* () { stack: nextStack, }); } - }); + }, Effect.withSpan("PullRequestSyncReactor.syncEntry")); - const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( - key: string, - entries: ReadonlyArray, - ) { + const syncGroup = Effect.fnUntraced(function* (key: string, entries: ReadonlyArray) { const first = entries[0]!; const ref = { projectId: first.thread.projectId, @@ -554,7 +553,7 @@ export const make = Effect.gen(function* () { ), { discard: true }, ); - }); + }, Effect.withSpan("PullRequestSyncReactor.syncGroup")); const skips = new Map(); const readGroup = (key: string, entries: ReadonlyArray, pauseKey: string) => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index d8ac4769e140f..1015e44694c81 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -447,16 +447,18 @@ const composerSelection = { const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* ( name: string, canRestart = false, + canSteer = true, ) { const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; const steered: string[] = []; + const interrupted: ProviderTurnId[] = []; const capabilities = { ...CodexProviderCapabilitiesV2, turns: { ...CodexProviderCapabilitiesV2.turns, - supportsActiveSteering: true, + supportsActiveSteering: canSteer, supportsSteeringByInterruptRestart: canRestart, }, }; @@ -533,7 +535,10 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* Effect.sync(() => { steered.push(turn.message.text); }), - interruptTurn: () => Effect.void, + interruptTurn: ({ providerTurnId }) => + Effect.sync(() => { + interrupted.push(providerTurnId); + }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => Effect.die("unused"), rollbackThread: () => Effect.die("unused"), @@ -588,7 +593,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* yield* Fiber.join(running); return threadId; }); - return { cwd, events, started, steered, layer, startFirstTurn }; + return { cwd, events, started, steered, interrupted, layer, startFirstTurn }; }); it.effect("sends a selected held message once and preserves the remaining queue order", () => @@ -938,6 +943,82 @@ it.effect("starts a steer that missed the turn on the saved next-turn selection" ), ); +// regression: #26695 cancelling a Copilot prompt to steer it kills its background agents +it.effect("queues a steer until the turn ends when the provider cannot steer it", () => + Effect.scoped( + Effect.gen(function* () { + const { events, started, steered, interrupted, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("steering-unsupported", false, false); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; + const messageId = MessageId.make("message:steer"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId, + text: "follow up", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + deliveryIntent: "steer", + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + + const held = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, []); + assert.deepEqual(interrupted, []); + assert.equal(started.length, 1); + assert.equal(held.runs.find((run) => run.id === first.runId)?.status, "running"); + const queued = held.runs.find((run) => run.userMessageId === messageId); + assert.equal(queued?.status, "queued"); + + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turn = held.providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + + assert.deepEqual(interrupted, []); + assert.equal(started.length, 2); + assert.equal(started[1]?.runId, queued?.id); + assert.equal(started[1]?.message.text, "follow up"); + }).pipe(Effect.provide(layer)); + }), + ), +); + // Codex flushes held drafts after the final answer; reserve their pre-steer position before turn end. it.effect("keeps a held commentary draft before the steer when turn end flushes it", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 4fc416023fb99..914ce43864ac5 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1383,6 +1383,9 @@ it.effect("replays a server-allocated launch", () => assert.equal(first.threadId, retry.threadId); assert.isFalse(first.resumed); assert.isTrue(retry.resumed); + // Clients drop their pending copy once the thread reaches the message's commit. + assert.isNumber(first.sequence); + assert.equal(retry.sequence, first.sequence); assert.equal(harness.runSetup.mock.calls.length, 1); assert.equal(retry.projection.messages.length, 1); assert.equal(retry.projection.runs.length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 289df535c12ea..7a45a54a312f4 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -123,6 +123,7 @@ export interface ThreadLaunchResult { readonly projection: OrchestrationV2ThreadProjection; /** The launch's last write (the initial message, else the thread claim) was accepted before this call. */ readonly resumed: boolean; + readonly sequence?: number; } export class ThreadLaunchError extends Schema.TaggedError()( @@ -941,6 +942,7 @@ const make = Effect.gen(function* () { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } let runId: RunId | null = null; + let messageSequence: number | undefined; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); @@ -987,6 +989,7 @@ const make = Effect.gen(function* () { ); // A concurrent retry can read no receipt and still replay the dispatch. messageWasAlreadyAccepted ||= dispatched.replayed === true; + messageSequence = dispatched.sequence; const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -1053,6 +1056,7 @@ const make = Effect.gen(function* () { input.initialMessage === undefined ? Option.isSome(launchReceipt) || claimed.replayed === true : messageWasAlreadyAccepted, + ...(messageSequence === undefined ? {} : { sequence: messageSequence }), }; }); }, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7aebdd010712f..fa70245fce768 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -280,12 +280,14 @@ export class ThreadManagementProjectionLoadError extends Schema.TaggedError()( "ThreadManagementProjectThreadsListError", { - projectId: ProjectId, + projectId: Schema.optional(ProjectId), cause: Schema.Defect(), }, ) { override get message(): string { - return `Unable to list threads in project ${this.projectId}.`; + return this.projectId === undefined + ? "Unable to list threads." + : `Unable to list threads in project ${this.projectId}.`; } } @@ -405,8 +407,9 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; + /** Omit `projectId` to list every project's threads. */ readonly listProjectThreads: (input: { - readonly projectId: ProjectId; + readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; }) => Effect.Effect, ThreadManagementError>; readonly sendToThread: ( @@ -737,27 +740,32 @@ const make = Effect.gen(function* () { ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => - orchestrator.getShellSnapshot({ projectId: input.projectId, location: "active" }).pipe( - Effect.mapError( - (cause) => - new ThreadManagementProjectThreadsListError({ - projectId: input.projectId, - cause, - }), - ), - Effect.map((snapshot) => - snapshot.threads - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || - right.id.localeCompare(left.id), - ), - ), - ); + orchestrator + .getShellSnapshot({ + location: "active", + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + }) + .pipe( + Effect.mapError( + (cause) => + new ThreadManagementProjectThreadsListError({ + projectId: input.projectId, + cause, + }), + ), + Effect.map((snapshot) => + snapshot.threads + .filter( + (thread) => + input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || + right.id.localeCompare(left.id), + ), + ), + ); const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( "ThreadManagementService.sendToThread", diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9402123145583..05030b6508a6b 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -37,6 +37,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -76,6 +77,7 @@ import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "../project/ScratchThreadPromotion.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; @@ -5782,6 +5784,198 @@ it.layer(layerTest)("snooze queued runs", (it) => { ); }); +const SCRATCH_ROOT = "/tmp/runtime-layer-promotion-scratch"; +const SCRATCH_PROJECT_ID = ProjectId.make("runtime-layer-promotion-scratch"); +const layerScratchPromotionTest = ScratchThreadPromotion.layer.pipe( + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedSome(SCRATCH_ROOT), + isScratchProject: (projectId) => Effect.succeed(projectId === SCRATCH_PROJECT_ID), + namedProjectsRoot: "/projects", + }), + ), + Layer.provide(NodeServices.layer), + Layer.provideMerge(layerProjectDeletionTest), +); + +it.layer(layerScratchPromotionTest)("turning a No project thread into a project", (it) => { + const setup = Effect.fn(function* (name: string) { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const folder = `${SCRATCH_ROOT}/2026-01-01-${name}`; + if (Option.isNone(yield* projects.getById(SCRATCH_PROJECT_ID))) { + yield* projects.create({ + commandId: CommandId.make("promotion-scratch-create"), + projectId: SCRATCH_PROJECT_ID, + title: "No project", + workspaceRoot: SCRATCH_ROOT, + }); + } + const createThread = (suffix: string, worktreePath: string) => + orchestrator + .dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`promotion-${name}-${suffix}-create`), + threadId: ThreadId.make(`promotion-${name}-${suffix}`), + projectId: SCRATCH_PROJECT_ID, + title: `${name} ${suffix}`, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath, + }) + .pipe(Effect.as(ThreadId.make(`promotion-${name}-${suffix}`))); + return { folder, createThread }; + }); + const shellOf = (threadId: ThreadId) => + Orchestrator.OrchestratorV2.pipe( + Effect.flatMap((orchestrator) => orchestrator.getThreadShell(threadId)), + ); + + it.effect("moves every thread in the folder and its bound tasks into one project", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const tasks = yield* ScheduledTaskService.ScheduledTaskService; + const { folder, createThread } = yield* setup("moves"); + const threadId = yield* createThread("main", folder); + const siblingId = yield* createThread("sibling", folder); + const otherId = yield* createThread("other", `${SCRATCH_ROOT}/2026-01-01-elsewhere`); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("promotion-moves-sibling-archive"), + threadId: siblingId, + }); + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("promotion-moves-bound"), + title: "Bound", + prompt: "Check in", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: SCRATCH_PROJECT_ID, + threadId: siblingId, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + }); + + const { projectId } = yield* promotion.promote({ threadId }); + + const project = yield* projects.getById(projectId); + assert.equal(Option.getOrUndefined(project)?.workspaceRoot, folder); + assert.equal(Option.getOrUndefined(project)?.title, "moves main"); + for (const id of [threadId, siblingId]) { + const shell = yield* shellOf(id); + assert.equal(shell?.projectId, projectId); + assert.isNull(shell?.worktreePath); + } + assert.equal((yield* shellOf(otherId))?.projectId, SCRATCH_PROJECT_ID); + const saved = (yield* tasks.list()).tasks.find((candidate) => candidate.id === task.id); + assert.equal(saved?.projectId, projectId); + assert.deepEqual(yield* promotion.promote({ threadId }), { projectId }); + }), + ); + + it.effect("changes nothing while a thread in the folder is busy", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { folder, createThread } = yield* setup("busy"); + const threadId = yield* createThread("idle", folder); + const busyId = yield* createThread("delegate", folder); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("promotion-busy-message"), + threadId: busyId, + messageId: MessageId.make("promotion-busy-message"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const error = yield* promotion.promote({ threadId }).pipe(Effect.flip); + + assert.equal(error.message, '"busy delegate" is still working. Finish or stop it first.'); + assert.isTrue(Option.isNone(yield* projects.getByWorkspaceRoot(folder))); + assert.equal((yield* shellOf(threadId))?.projectId, SCRATCH_PROJECT_ID); + }), + ); + + it.effect("finishes a partial move from either thread", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { folder, createThread } = yield* setup("partial"); + const movedId = yield* createThread("moved", folder); + const leftId = yield* createThread("left", folder); + const earlier = yield* projects.create({ + commandId: CommandId.make("promotion-partial-project"), + projectId: ProjectId.make("promotion-partial-project"), + title: "Earlier", + workspaceRoot: folder, + }); + yield* orchestrator.dispatch({ + type: "thread.project.adopt-folder", + commandId: CommandId.make("promotion-partial-adopt"), + threadId: movedId, + projectId: earlier.id, + }); + + assert.deepEqual(yield* promotion.promote({ threadId: movedId }), { projectId: earlier.id }); + assert.equal((yield* shellOf(leftId))?.projectId, earlier.id); + }), + ); + + it.effect("refuses a thread outside No project", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make("promotion-regular-project"); + const threadId = ThreadId.make("promotion-regular-thread"); + yield* projects.create({ + commandId: CommandId.make("promotion-regular-project"), + projectId, + title: "Regular", + workspaceRoot: "/tmp/runtime-layer-promotion-regular", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("promotion-regular-thread-create"), + threadId, + projectId, + title: "Regular", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + + const error = yield* promotion.promote({ threadId }).pipe(Effect.flip); + + assert.equal( + error.message, + "Only a No project thread with its own folder can become a project.", + ); + }), + ); +}); + it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { it.effect("carries snooze state through the V2 shell projection", () => Effect.gen(function* () { @@ -5877,6 +6071,88 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.isNull(awakened.thread.snoozedAt); }), ); + + it.effect("moves a thread into a project rooted at its own folder", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const scratchProjectId = ProjectId.make("runtime-layer-adopt-scratch"); + const folderProjectId = ProjectId.make("runtime-layer-adopt-folder"); + const otherProjectId = ProjectId.make("runtime-layer-adopt-other"); + const folder = "/tmp/runtime-layer-adopt-scratch/2026-01-01-notes-a1b2c3d4"; + const createProject = (projectId: ProjectId, workspaceRoot: string) => + projects.create({ + commandId: CommandId.make(`${projectId}-create`), + projectId, + title: projectId, + workspaceRoot, + }); + const createThread = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-create`), + threadId, + projectId: scratchProjectId, + title: "Scratch work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: folder, + }); + const adopt = (threadId: ThreadId, projectId: ProjectId, suffix: string) => + orchestrator.dispatch({ + type: "thread.project.adopt-folder", + commandId: CommandId.make(`${threadId}-adopt-${suffix}`), + threadId, + projectId, + }); + yield* createProject(scratchProjectId, "/tmp/runtime-layer-adopt-scratch"); + yield* createProject(folderProjectId, folder); + yield* createProject(otherProjectId, "/tmp/runtime-layer-adopt-other"); + const idleThreadId = ThreadId.make("runtime-layer-adopt-idle"); + const busyThreadId = ThreadId.make("runtime-layer-adopt-busy"); + yield* createThread(idleThreadId); + yield* createThread(busyThreadId); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-adopt-busy-message"), + threadId: busyThreadId, + messageId: MessageId.make("runtime-layer-adopt-busy-message"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const otherFolder = yield* adopt(idleThreadId, otherProjectId, "other").pipe(Effect.flip); + assert.equal( + otherFolder.cause, + `Thread ${idleThreadId} does not work in /tmp/runtime-layer-adopt-other.`, + ); + const missing = yield* adopt(idleThreadId, ProjectId.make("missing"), "missing").pipe( + Effect.flip, + ); + assert.equal(missing.cause, `Thread ${idleThreadId} cannot move to missing project missing.`); + const busy = yield* adopt(busyThreadId, folderProjectId, "busy").pipe(Effect.flip); + assert.equal( + busy.cause, + `Thread ${busyThreadId} still has work in progress. Finish or stop it first.`, + ); + + yield* adopt(idleThreadId, folderProjectId, "folder"); + const shell = yield* orchestrator.getShellSnapshot(); + const moved = shell.threads.find((thread) => thread.id === idleThreadId); + assert.equal(moved?.projectId, folderProjectId); + assert.isNull(moved?.worktreePath); + const stayed = shell.threads.find((thread) => thread.id === busyThreadId); + assert.equal(stayed?.projectId, scratchProjectId); + }), + ); }); it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index b4ee0a56bc9c8..99c99ff72702e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -14,6 +14,7 @@ import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "../project/ScratchThreadPromotion.ts"; import * as CheckpointCaptureService from "./CheckpointCaptureService.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; @@ -286,6 +287,16 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe( ), ), ); +const layerScratchThreadPromotionProvided = ScratchThreadPromotion.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProjectService, + layerManagedProjectFoldersProvided, + layerThreadManagementProvided, + layerScheduledTaskProvided, + ), + ), +); const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -359,6 +370,7 @@ export const layerProduction = Layer.mergeAll( layerManagedProjectFoldersProvided, layerThreadLaunchProvided, layerScheduledTaskProvided, + layerScratchThreadPromotionProvided, layerSecretRequestsProvided, // Coder: guest sharing is not carried, so there is no GuestShareService (`guest/` is cut). UsageLimitRecoveryWorker.layer.pipe( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 64a95e315bc8b..bf69d030d5062 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -38,6 +38,7 @@ import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "../ProviderSessionManager.ts"; import * as CommandOutputRecorder from "./CommandOutputRecorder.ts"; @@ -249,6 +250,10 @@ export function layerWithRegistry( const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerStores, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts new file mode 100644 index 0000000000000..8a24b95028875 --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mergeWorkflowAgentMetadata } from "./workflowAgentAnswers.ts"; + +describe("mergeWorkflowAgentMetadata", () => { + it("keeps effort and speed across reads of a split assistant message", () => { + const metadata = mergeWorkflowAgentMetadata([ + { + type: "assistant", + effort: "xhigh", + message: { id: "a", content: [], usage: { speed: "standard" } }, + }, + ]); + expect(metadata).toEqual({ messageId: "a", effort: "xhigh", speed: "standard" }); + expect( + mergeWorkflowAgentMetadata( + [{ type: "assistant", message: { id: "a", content: [{ type: "text", text: "Done" }] } }], + metadata, + ), + ).toEqual(metadata); + expect(mergeWorkflowAgentMetadata([], metadata)).toEqual(metadata); + }); + + it("ignores bookkeeping and API errors without clearing prior metadata", () => { + const metadata = { messageId: "a", effort: "max", speed: "standard" }; + expect( + mergeWorkflowAgentMetadata( + [ + { type: "user", effort: "high", message: { usage: { speed: "fast" } } }, + { type: "assistant", isMeta: true, effort: "high" }, + { type: "assistant", isApiErrorMessage: true, message: { id: "error" } }, + { type: "assistant", effort: 1, message: { id: "a", usage: { speed: false } } }, + ], + metadata, + ), + ).toEqual(metadata); + }); + + it.each(["b", undefined])("clears omitted metadata for a new response with id %s", (id) => { + expect( + mergeWorkflowAgentMetadata([{ type: "assistant", message: { id, content: [] } }], { + messageId: "a", + effort: "max", + speed: "standard", + }), + ).toEqual(id === undefined ? {} : { messageId: id }); + }); +}); diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts new file mode 100644 index 0000000000000..d96f3684a4e2b --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts @@ -0,0 +1,33 @@ +import type { OrchestrationV2WorkflowAgent } from "@t3tools/contracts"; + +import { field, text } from "./unknownField.ts"; + +export type WorkflowAgentMetadata = Pick & { + readonly messageId?: string; +}; + +export function mergeWorkflowAgentMetadata( + lines: ReadonlyArray, + previous: WorkflowAgentMetadata = {}, +): WorkflowAgentMetadata { + let metadata = previous; + for (const entry of lines) { + if ( + field(entry, "type") !== "assistant" || + field(entry, "isMeta") === true || + field(entry, "isApiErrorMessage") === true + ) + continue; + const message = field(entry, "message"); + const messageId = text(field(message, "id")); + const effort = text(field(entry, "effort")); + const speed = text(field(field(message, "usage"), "speed")); + metadata = { + ...(messageId !== undefined && messageId === metadata.messageId ? metadata : {}), + ...(messageId === undefined ? {} : { messageId }), + ...(effort === undefined ? {} : { effort }), + ...(speed === undefined ? {} : { speed }), + }; + } + return metadata; +} diff --git a/apps/server/src/project/ScratchThreadPromotion.ts b/apps/server/src/project/ScratchThreadPromotion.ts new file mode 100644 index 0000000000000..671a480a95832 --- /dev/null +++ b/apps/server/src/project/ScratchThreadPromotion.ts @@ -0,0 +1,161 @@ +import { + CommandId, + type OrchestrationV2ThreadShell, + ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { threadHasUnfinishedWork } from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ManagedProjectFolders from "./ManagedProjectFolders.ts"; +import * as ProjectService from "./ProjectService.ts"; + +export class ScratchThreadPromotionError extends Schema.TaggedError()( + "ScratchThreadPromotionError", + { + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason; + } +} + +export class ScratchThreadPromotion extends Context.Service< + ScratchThreadPromotion, + { + /** + * Turns a No project thread's folder into a project, in place, and moves every + * thread working in that folder into it. Running it again finishes a partial move. + */ + readonly promote: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect<{ readonly projectId: ProjectId }, ScratchThreadPromotionError>; + } +>()("t3/project/ScratchThreadPromotion") {} + +const make = Effect.gen(function* () { + const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const projects = yield* ProjectService.ProjectService; + const threads = yield* ThreadManagementService.ThreadManagementService; + const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + + const fail = (reason: string) => (cause: unknown) => + new ScratchThreadPromotionError({ reason, cause }); + + const promote: ScratchThreadPromotion["Service"]["promote"] = Effect.fn( + "ScratchThreadPromotion.promote", + )(function* (input) { + const thread = yield* threads.getThreadShell(input.threadId).pipe( + Effect.mapError(fail("Could not read the thread.")), + Effect.filterOrFail( + (shell) => shell !== null, + () => new ScratchThreadPromotionError({ reason: "Thread not found." }), + ), + ); + const project = yield* projects + .getById(thread.projectId) + .pipe(Effect.mapError(fail("Could not read the thread's project."))); + const folder = thread.worktreePath ?? Option.getOrUndefined(project)?.workspaceRoot; + const scratchRoot = Option.getOrUndefined(yield* managedFolders.scratchRoot); + const isScratch = yield* managedFolders.isScratchProject(thread.projectId); + // A thread already rooted at its folder is a partial earlier promotion; finish it. + const alreadyPromoted = + Option.isSome(project) && project.value.workspaceRoot === folder && !isScratch; + if ( + folder === undefined || + scratchRoot === undefined || + path.dirname(folder) !== scratchRoot || + !(isScratch || alreadyPromoted) + ) { + return yield* new ScratchThreadPromotionError({ + reason: "Only a No project thread with its own folder can become a project.", + }); + } + + const snapshot = yield* Effect.flatten( + threads.readShellSnapshot({ worktreePath: folder }), + ).pipe(Effect.mapError(fail("Could not read the threads in this folder."))); + const moveSet = new Map(); + if (isScratch) moveSet.set(thread.id, thread); + for (const candidate of [...snapshot.threads, ...snapshot.archivedThreads]) { + if (yield* managedFolders.isScratchProject(candidate.projectId)) { + moveSet.set(candidate.id, candidate); + } + } + + for (const candidate of moveSet.values()) { + const records = yield* threads + .getThreadRecords(candidate.id, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe(Effect.mapError(fail(`Could not read "${candidate.title}".`))); + if (threadHasUnfinishedWork(records)) { + return yield* new ScratchThreadPromotionError({ + reason: `"${candidate.title}" is still working. Finish or stop it first.`, + }); + } + } + + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const projectId = yield* createProject(thread.title, folder); + yield* scheduledTasks + .moveThreadTasks({ threadIds: [...moveSet.keys()], projectId }) + .pipe(Effect.mapError(fail("Could not move the thread's scheduled tasks."))); + for (const candidate of moveSet.values()) { + const commandId = CommandId.make(`scratch-promotion:${yield* randomId}`); + yield* threads + .dispatch({ + type: "thread.project.adopt-folder", + commandId, + threadId: candidate.id, + projectId, + }) + .pipe(Effect.mapError(fail(`Could not move "${candidate.title}". Try again.`))); + } + return { projectId }; + }), + ); + }); + + const randomId = crypto + .randomUUIDv4() + .pipe(Effect.mapError(fail("Could not create an id for the project."))); + + // bootstrap returns the project that already owns the folder, so retries reuse it. + const createProject = Effect.fn("ScratchThreadPromotion.createProject")(function* ( + title: string, + workspaceRoot: string, + ) { + const id = yield* randomId; + return yield* projects + .bootstrap({ + commandId: CommandId.make(`scratch-promotion-project:${id}`), + projectId: ProjectId.make(id), + title, + workspaceRoot, + }) + .pipe( + Effect.map(({ project }) => project.id), + // bootstrap reads before locking; racing creates return the winner in a conflict. + Effect.catchTags({ + ProjectConflictError: (conflict) => Effect.succeed(conflict.conflictingProjectId), + }), + Effect.mapError(fail("Could not create the project.")), + ); + }); + + return ScratchThreadPromotion.of({ promote }); +}); + +export const layer = Layer.effect(ScratchThreadPromotion, make); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 31961f9a701c9..f96116dee0dea 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -1,5 +1,6 @@ import * as NodeAssert from "node:assert/strict"; +import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { t3ToolBridgeSections } from "@t3tools/provider-core/server/t3ToolBridgeInstructions"; import { describe, it } from "vite-plus/test"; @@ -100,6 +101,18 @@ describe("Codex orchestration context", () => { }); }); +describe("Codex device context", () => { + it("adds the shared device block only when the session holds device access", () => { + const tools = (device: boolean) => + buildCodexAdditionalContext({ model: "gpt-6.1-sol" }, { browser: true, device }).t3_code_tools + ?.value ?? ""; + + NodeAssert.ok(tools(true).includes(T3_CODE_DEVICE_TOOL_INSTRUCTIONS)); + NodeAssert.ok(!tools(true).includes("ToolSearch")); + NodeAssert.ok(!tools(false).includes("## T3 Code devices")); + }); +}); + function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2006111146164..6f02cf2bc04ce 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -4,16 +4,13 @@ import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeI import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, T3_CODE_ORCHESTRATION_SECTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; /** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000; -const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices - -For iOS Simulators and Android Emulators here, call \`device_list\`, then \`device_open\`. Drive the device with the exact \`agent-device\` launcher and flags it returns, on every command. Prefer \`agent-device snapshot -i\` refs over coordinates. Use \`device_screenshot\` to see the screen. Use \`xcrun simctl\` or \`adb\` only for builds, logs, and ports. If a platform is unavailable, say so.`; - export interface T3CodeToolAvailability { readonly browser: boolean; readonly device: boolean; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 522aa944d8ada..2ebc6f40775c8 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -265,6 +265,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index b42932fcc6668..87ed61776ea9c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -75,8 +75,12 @@ { "driver": "antigravity", "t3CodeRange": ">=0.0.42", - "recommendedRange": "=1.1.1", - "ranges": [{ "range": "=1.1.1", "status": "supported" }] + "recommendedRange": "=1.4.1", + "ranges": [ + { "range": "=1.4.1", "status": "supported" }, + { "range": "=1.3.0", "status": "supported" }, + { "range": "=1.1.1", "status": "supported" } + ] }, { "driver": "pi", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 774436ce07acc..a6e5ec7e6f6eb 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -111,6 +111,65 @@ function hostedChangeRequest(body: string, additions = 1) { }; } +it.effect("reads maintainer edits and changes them through the update command", () => + Effect.gen(function* () { + let enabled = true; + const provider = fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: () => + Effect.succeed({ ...hostedChangeRequest("Body"), maintainerCanModify: enabled }), + updateChangeRequest: (input) => + Effect.sync(() => { + assert.strictEqual(input.title, undefined); + assert.strictEqual(input.body, undefined); + enabled = input.maintainerCanModify!; + }), + }); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + { + ...provider, + capabilities: { + ...provider.capabilities, + edit: { changeRequest: true, comment: true, maintainerEdits: true }, + }, + }, + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.detail(ref)).maintainerCanModify, true); + yield* service.update({ ...ref, maintainerCanModify: false }); + assert.strictEqual((yield* service.detail(ref)).maintainerCanModify, false); + }), +); + +it.effect("refuses maintainer edits on a host without the capability", () => + Effect.gen(function* () { + let writes = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + updateChangeRequest: () => + Effect.sync(() => { + writes += 1; + }), + }), + ], + }); + const result = yield* service + .update({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + maintainerCanModify: false, + }) + .pipe(Effect.flip); + assert.strictEqual(result._tag, "PullRequestOperationError"); + assert.strictEqual(writes, 0); + }), +); + it.effect("does not wait for an in-flight detail read to display a preview", () => Effect.gen(function* () { const detailStarted = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 274b6518ab293..8062447694ab3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1647,6 +1647,9 @@ export const make = Effect.gen(function* () { ...(changeRequest.autoMergeMethod === undefined ? {} : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: changeRequest.maintainerCanModify }), ...(changeRequest.workflowApprovalsRequired === undefined ? {} : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), @@ -2019,7 +2022,22 @@ export const make = Effect.gen(function* () { }), ); } - if (input.title === undefined && input.body === undefined) { + if ( + input.maintainerCanModify !== undefined && + project.api.capabilities.edit?.maintainerEdits !== true + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "update", + detail: "This host does not support maintainer edits.", + }), + ); + } + if ( + input.title === undefined && + input.body === undefined && + input.maintainerCanModify === undefined + ) { return Effect.fail( new PullRequestOperationError({ operation: "update", @@ -2032,6 +2050,9 @@ export const make = Effect.gen(function* () { repository: project.repository, host: project.host, number: input.number, + ...(input.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: input.maintainerCanModify }), ...(input.title === undefined ? {} : { title: input.title }), ...(input.body === undefined ? {} : { body: input.body }), }).pipe(Effect.mapError(toPullRequestError("update"))); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index c1dcdd22abbae..82d165972750f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -402,3 +402,77 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("a bound run follows its thread when promotion moves it mid-send", () => + Effect.gen(function* () { + const scratch = ProjectId.make("project:scratch"); + const promoted = ProjectId.make("project:promoted"); + const sends = yield* Ref.make>([]); + // Rebind lands first, then the promotion commits between the shell read and the retry. + const shellProjects = yield* Ref.make([scratch, promoted]); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: (input) => + Ref.updateAndGet(sends, (all) => [ + ...all, + { projectId: input.projectId, commandId: input.commandId }, + ]).pipe( + Effect.flatMap((all) => + all.length < 3 + ? Effect.fail( + new ThreadManagementService.ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), + ) + : Effect.succeed({} as never), + ), + ), + getThreadShell: () => + Ref.modify(shellProjects, ([next, ...rest]) => [ + { projectId: next ?? promoted } as never, + rest, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const threadId = ThreadId.make("thread:promoted-during-run"); + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:promoted-during-run"), + title: "Check in", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: promoted, + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + + const result = yield* service.runNow({ id: task.id }); + + assert.equal(result.threadId, threadId); + const attempts = yield* Ref.get(sends); + assert.deepEqual( + attempts.map((attempt) => attempt.projectId), + [promoted, scratch, promoted], + ); + assert.equal(new Set(attempts.map((attempt) => attempt.commandId)).size, 1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index f5514c5bdd1f9..9ca18a8473c60 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -2,6 +2,7 @@ import { CommandId, MessageId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ProjectId, ScheduledTask, ScheduledTaskError, ScheduledTaskId, @@ -237,6 +238,11 @@ export class ScheduledTaskService extends Context.Service< ) => Effect.Effect; /** Deletes only tasks that still target this thread, including their webhook deliveries. */ readonly deleteForThread: (threadId: ThreadId) => Effect.Effect; + /** Points tasks bound to these threads at the project the threads moved to. */ + readonly moveThreadTasks: (input: { + readonly threadIds: ReadonlyArray; + readonly projectId: ProjectId; + }) => Effect.Effect; /** The dispatched thread, or null if dispatch failed; leaves the task's binding unchanged. */ readonly runNow: ( input: ScheduledTaskRunNowInput, @@ -805,6 +811,42 @@ export const layer = Layer.effect( // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; + // A bound thread can move to another project (No project to a project) while + // this fires. Follow it; one move needs at most two retries. Same ids replay safely. + const sendBound = ( + threadId: ThreadId, + projectId: ProjectId, + retries: number, + ): ReturnType => + threadManagement + .sendToThread({ + projectId, + commandId, + threadId, + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + mode: "queue", + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe( + Effect.catchTags({ + ThreadManagementThreadNotFoundError: (error) => + retries === 0 + ? Effect.fail(error) + : threadManagement.getThreadShell(threadId).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((shell) => + shell === null || shell.projectId === projectId + ? Effect.fail(error) + : sendBound(threadId, shell.projectId, retries - 1), + ), + ), + }), + ); + // Capture defects and interrupts too so dispatch failures still reach markCompleted. const result = active.threadId === null @@ -832,20 +874,9 @@ export const layer = Layer.effect( : yield* Effect.exit( // The stored selection only seeds fresh threads. A bound run // follows the thread's current provider, so it passes none. - threadManagement - .sendToThread({ - projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), - messageId, - scheduledTaskId: active.id, - text: prompt, - attachments: [], - mode: "queue", - createdBy: active.createdBy, - creationSource: active.creationSource, - }) - .pipe(Effect.as(ThreadId.make(active.threadId))), + sendBound(ThreadId.make(active.threadId), active.projectId, 2).pipe( + Effect.as(ThreadId.make(active.threadId)), + ), ); const runSucceeded = result._tag === "Success"; @@ -1259,6 +1290,22 @@ export const layer = Layer.effect( if (removed.length > 0) yield* notifyChanged; }); + const moveThreadTasks: ScheduledTaskService["Service"]["moveThreadTasks"] = Effect.fn( + "ScheduledTaskService.moveThreadTasks", + )(function* (input) { + if (input.threadIds.length === 0) return; + const moved = yield* sql<{ task_id: ScheduledTaskId }>` + UPDATE scheduled_tasks SET project_id = ${input.projectId} + WHERE project_id != ${input.projectId} AND thread_id IN ${sql.in(input.threadIds)} + RETURNING task_id + `.pipe( + Effect.mapError((cause) => + taskError("Could not move the thread's schedule tasks.", { cause }), + ), + ); + if (moved.length > 0) yield* notifyChanged; + }); + const runNow: ScheduledTaskService["Service"]["runNow"] = Effect.fn( "ScheduledTaskService.runNow", )(function* (input: ScheduledTaskRunNowInput) { @@ -2129,6 +2176,7 @@ export const layer = Layer.effect( setEnabled, delete: deleteTask, deleteForThread, + moveThreadTasks, runNow, rotateWebhookToken, listWebhookDeliveries, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3131c84d880e7..a1e0d34c7dd53 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -435,6 +435,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "reviewGuideModelSelection", "textGenerationModelSelection", + "fallbackModelSelection", "pullRequestMergeMethod", ]); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d9541911d21b..866186dc06cf7 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -4,6 +4,7 @@ import { CodexSettings, EasDeviceHostConfig, type ServerSettings, + SimlockDeviceHostConfig, UsageLimitSourceConfig, secretSettingsFields, } from "@t3tools/contracts"; @@ -31,6 +32,7 @@ const providerSecretFields = new Map([ ]); const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); +const simlockDeviceHostSecretFields = secretSettingsFields(SimlockDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); export interface SettingsSecret { @@ -153,5 +155,24 @@ export function mapSettingsSecrets( ) as EasDeviceHostConfig, ); - return { ...settings, providerInstances, sourceControlHosts, usageLimitSources, easDeviceHosts }; + // Its own prefix: an EAS and a Simlock host may share an id. + const simlockDeviceHosts = settings.simlockDeviceHosts.map( + (host) => + mapFields( + host, + simlockDeviceHostSecretFields, + (field, serverHost) => + `simlock-device-host-${encode(host.id)}-${encode(field)}` + + (serverHost === null ? "" : `-${encode(serverHost)}`), + ) as SimlockDeviceHostConfig, + ); + + return { + ...settings, + providerInstances, + sourceControlHosts, + usageLimitSources, + easDeviceHosts, + simlockDeviceHosts, + }; } diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 96f266c6f8959..0b0a80d80d65c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -56,6 +56,7 @@ const resolveInstance = ( export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + // Coder: no out-of-usage fallback (#26164); it reads plan-window usage, which is not carried. return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { return resolveInstance( diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index bc3358738f023..53d58fd80a21b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -11,17 +11,19 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; +import * as Scheduler from "effect/Scheduler"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import type { VcsStatusLocalResult, VcsStatusRemoteResult, - VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; // Coder: exercise the fork settings policy and lightweight ref-stream contract. import { DEFAULT_SERVER_SETTINGS, + GitCommandError, GitManagerError, ProjectId, type ServerSettings as ServerSettingsValue, @@ -67,16 +69,11 @@ const remoteStatusWithPr: VcsStatusRemoteResult = { // Coder: keep the merge-request fixture within the GitLab-only contract. url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/2978", baseRef: "main", - headRef: "codex/connection-state-audit", + headRef: "upstream-topic", state: "open", }, }; -const baseStatus: VcsStatusResult = { - ...baseLocalStatus, - ...baseRemoteStatus, -}; - function layerTestFor(state: { currentLocalStatus: VcsStatusLocalResult; currentRemoteStatus: VcsStatusRemoteResult | null; @@ -85,12 +82,19 @@ function layerTestFor(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + remoteStatusError?: GitManagerError; backgroundWorkEnabled?: boolean; automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; + fileSystem?: FileSystem.FileSystem; + pullCurrentBranch?: GitWorkflowService.GitWorkflowService["Service"]["pullCurrentBranch"]; }) { return VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + state.fileSystem + ? Layer.succeed(FileSystem.FileSystem, state.fileSystem) + : NodeServices.layer, + ), Layer.provide(layerBackgroundPolicy(() => state.backgroundWorkEnabled !== false)), Layer.provide( Layer.succeed( @@ -105,16 +109,22 @@ function layerTestFor(state: { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName), remoteStatus: (_input, options) => - Effect.suspend(() => state.beforeRemoteStatus ?? Effect.void).pipe( - Effect.andThen( - Effect.sync(() => { - state.remoteStatusCalls += 1; - state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); - return state.currentRemoteStatus; - }), - ), - ), + Effect.suspend(() => { + return (state.beforeRemoteStatus ?? Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + return state.currentRemoteStatus; + }), + ), + Effect.tap(() => + state.remoteStatusError ? Effect.fail(state.remoteStatusError) : Effect.void, + ), + ); + }), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -128,6 +138,7 @@ function layerTestFor(state: { state.localInvalidationCalls += 1; state.remoteInvalidationCalls += 1; }), + ...(state.pullCurrentBranch ? { pullCurrentBranch: state.pullCurrentBranch } : {}), }), ), ); @@ -159,7 +170,7 @@ describe("VcsStatusBroadcaster", () => { Stream.tap((event) => event._tag === "snapshot" ? Deferred.succeed(subscribed, undefined) : Effect.void, ), - Stream.take(2), + Stream.take(3), Stream.runCollect, Effect.forkScoped, ); @@ -171,12 +182,11 @@ describe("VcsStatusBroadcaster", () => { assert.isNull(refreshed.pr); assert.equal(state.localInvalidationCalls, 1); assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 1); - assert.deepStrictEqual((yield* Fiber.join(events))[1], { - _tag: "snapshot", - local: state.currentLocalStatus, - remote: null, - }); + assert.equal(state.remoteInvalidationCalls, 2); + assert.deepStrictEqual((yield* Fiber.join(events)).slice(1), [ + { _tag: "remoteUpdated", remote: null }, + { _tag: "localUpdated", local: state.currentLocalStatus }, + ]); assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); const snapshot = yield* broadcaster .streamStatus({ cwd: "/repo", includeRemote: false }) @@ -208,7 +218,7 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(refreshed, initial); assert.deepStrictEqual(yield* broadcaster.getStatus({ cwd: "/repo" }), refreshed); assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 1); assert.equal(state.remoteStatusCalls, 1); }).pipe(Effect.provide(layerTestFor(state))); }, @@ -276,6 +286,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(localStatus), + currentBranch: () => Effect.succeed(localStatus.refName), remoteStatus: () => Effect.succeed(remoteStatus), invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, @@ -338,7 +349,7 @@ describe("VcsStatusBroadcaster", () => { const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(refreshed, remoteStatusWithPr); assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 2); state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; state.currentRemoteStatus = baseRemoteStatus; @@ -346,6 +357,11 @@ describe("VcsStatusBroadcaster", () => { const refreshedBranch = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(refreshedBranch, baseRemoteStatus); assert.equal(state.remoteStatusCalls, 3); + const refreshedStatus = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(refreshedStatus.refName, "feature/next"); + assert.isNull(refreshedStatus.pr); + assert.equal(state.remoteStatusCalls, 3); + assert.equal(state.remoteInvalidationCalls, 3); }).pipe(Effect.provide(layerTestFor(state))); }); @@ -359,6 +375,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(baseLocalStatus), + currentBranch: () => Effect.succeed(baseLocalStatus.refName), remoteStatus: () => Effect.gen(function* () { remoteReads += 1; @@ -404,6 +421,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(baseLocalStatus), + currentBranch: () => Effect.succeed(baseLocalStatus.refName), remoteStatus: () => Effect.gen(function* () { remoteReads += 1; @@ -414,6 +432,8 @@ describe("VcsStatusBroadcaster", () => { } return remoteStatusWithPr; }), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, invalidateStatus: () => Effect.void, }), ), @@ -434,7 +454,7 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); - it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { + it.effect("keeps the published local snapshot when a refresh remote read fails", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -454,6 +474,7 @@ describe("VcsStatusBroadcaster", () => { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.succeed(state.currentLocalStatus.refName), remoteStatus: () => Effect.suspend(() => { state.remoteStatusCalls += 1; @@ -499,10 +520,18 @@ describe("VcsStatusBroadcaster", () => { state.failRemoteStatus = true; const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + const snapshot = yield* Stream.runHead( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ); assert.isTrue(Exit.isFailure(refreshExit)); - assert.deepStrictEqual(cached, baseStatus); + assert.isTrue(Option.isSome(snapshot)); + const event = Option.getOrThrow(snapshot); + assert.equal(event._tag, "snapshot"); + if (event._tag === "snapshot") { + assert.equal(event.local.refName, "feature/partial-refresh"); + assert.isNull(event.remote); + } }).pipe(Effect.provide(testLayer)); }); @@ -529,6 +558,7 @@ describe("VcsStatusBroadcaster", () => { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.succeed(state.currentLocalStatus.refName), remoteStatus: (input) => Effect.sync(() => { seenCwds.push(input.cwd); @@ -611,6 +641,256 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state))); }); + it.effect("publishes local status while an earlier lookup holds the remote lock", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated") return Deferred.succeed(published, event.local); + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const earlier = yield* broadcaster.refreshPullRequestStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(started); + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const completed = yield* Deferred.make(); + const refresh = yield* broadcaster + .refreshStatus("/repo") + .pipe(Effect.ensuring(Deferred.succeed(completed, undefined)), Effect.forkScoped); + + assert.deepStrictEqual(yield* Deferred.await(published), state.currentLocalStatus); + assert.isFalse(yield* Deferred.isDone(completed)); + assert.isFalse(yield* Deferred.isDone(release)); + state.currentRemoteStatus = remoteStatusWithPr; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(earlier); + const status = yield* Fiber.join(refresh); + assert.isTrue(status.hasWorkingTreeChanges); + assert.deepStrictEqual(status.pr, remoteStatusWithPr.pr); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + + it.effect("publishes local changes before a remote error and propagates that error", () => { + const remoteError = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/repo", + detail: "The controlled remote lookup failed.", + }); + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated") return Deferred.succeed(published, event.local); + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const completed = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + state.remoteStatusError = remoteError; + const refresh = yield* broadcaster + .refreshStatus("/repo") + .pipe( + Effect.result, + Effect.ensuring(Deferred.succeed(completed, undefined)), + Effect.forkScoped, + ); + yield* Deferred.await(started); + assert.isFalse(yield* Deferred.isDone(completed)); + const pending = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: "/repo", includeRemote: false })), + ); + assert.equal(pending._tag, "snapshot"); + if (pending._tag === "snapshot") assert.isTrue(pending.local.hasWorkingTreeChanges); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(refresh); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.strictEqual(result.failure, remoteError); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.isTrue(cached.hasWorkingTreeChanges); + assert.deepStrictEqual(cached.pr, remoteStatusWithPr.pr); + assert.deepStrictEqual(yield* Deferred.await(published), state.currentLocalStatus); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + + it.effect.each(["getStatus", "refreshStatus", "autoPull", "autoPullFailure"] as const)( + "%s keeps response parts consistent when local state changes after the remote cache write", + (operation) => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + }; + const autoPull = operation === "autoPull" || operation === "autoPullFailure"; + if (autoPull) { + state.currentLocalStatus = { ...baseLocalStatus, isDefaultRef: true }; + state.currentRemoteStatus = { ...remoteStatusWithPr, aheadCount: 0, behindCount: 5 }; + state.pullCurrentBranch = () => + operation === "autoPullFailure" + ? Effect.fail( + new GitCommandError({ + operation: "test.pull", + command: "pull", + cwd: "/repo", + detail: "Pull failed.", + }), + ) + : Effect.succeed({ + status: "pulled", + refName: "feature/status-broadcast", + upstreamRef: "origin/main", + }); + } + const layerTest = layerTestFor(state).pipe( + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: () => Effect.succeed(autoPull), + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + let switched = false; + let remoteWritten = false; + const writeCompleted = yield* Deferred.make(); + const localRefresh = yield* Effect.gen(function* () { + yield* Deferred.await(writeCompleted); + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next-status" }; + yield* broadcaster.refreshLocalStatus("/repo"); + switched = true; + }).pipe(Effect.forkScoped); + const scheduler = new Scheduler.MixedScheduler(); + const shouldYield = scheduler.shouldYield.bind(scheduler); + scheduler.shouldYield = (fiber) => (remoteWritten && !switched) || shouldYield(fiber); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (time, exit) => { + end(time, exit); + if ( + options.name !== "VcsStatusBroadcaster.updateCachedRemoteStatus" || + remoteWritten + ) { + return; + } + // Yield this reader after its write until the local refresh finishes. + remoteWritten = true; + Deferred.doneUnsafe(writeCompleted, Effect.void); + }; + return span; + }, + }); + const status = yield* ( + operation === "getStatus" + ? broadcaster.getStatus({ cwd: "/repo" }) + : broadcaster.refreshStatus("/repo") + ).pipe(Effect.withTracer(tracer), Effect.provideService(Scheduler.Scheduler, scheduler)); + yield* Fiber.join(localRefresh); + assert.isTrue(switched); + assert.equal(status.refName, "feature/next-status"); + assert.isNull(status.pr); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }).pipe(Effect.provide(layerTest), Effect.scoped); + }, + ); + + it.effect("recovers a discarded branch lookup on the next permitted turn-end refresh", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const lookup = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(started); + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next-status" }; + yield* Deferred.succeed(release, undefined); + const discarded = yield* Fiber.join(lookup); + assert.equal(discarded.refName, state.currentLocalStatus.refName); + assert.isNull(discarded.pr); + assert.equal(discarded.aheadCount, 0); + assert.equal(discarded.behindCount, 0); + const snapshot = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: "/repo", includeRemote: false })), + ); + assert.equal(snapshot._tag, "snapshot"); + if (snapshot._tag === "snapshot") assert.isNull(snapshot.remote); + + state.beforeRemoteStatus = Effect.void; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + pr: { ...remoteStatusWithPr.pr!, number: 2979, headRef: "feature/next-status" }, + }; + state.backgroundWorkEnabled = false; + yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.equal(state.remoteStatusCalls, 1); + state.backgroundWorkEnabled = true; + const recovered = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(recovered, state.currentRemoteStatus); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(cached.refName, state.currentLocalStatus.refName); + assert.deepStrictEqual(cached.pr, state.currentRemoteStatus.pr); + assert.equal(cached.aheadCount, 2); + assert.equal(state.remoteStatusCalls, 2); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -632,6 +912,7 @@ describe("VcsStatusBroadcaster", () => { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.succeed(state.currentLocalStatus.refName), remoteStatus: () => Effect.sync(() => { state.remoteStatusCalls += 1; @@ -754,6 +1035,8 @@ describe("Coder ref status stream", () => { Layer.mock(GitWorkflowService.GitWorkflowService)({ localRefStatus: () => Effect.succeed({ isRepo: local.isRepo, refName: local.refName }), localStatus: () => Effect.sync(() => local), + currentBranch: () => Effect.sync(() => local.refName), + invalidateRemoteStatus: () => Effect.void, remoteStatus: () => Effect.sync(() => remote), invalidateLocalStatus: () => Effect.void, invalidateStatus: () => Effect.void, @@ -887,6 +1170,8 @@ describe("Coder auto-pull policy", () => { Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed({ ...baseLocalStatus, isDefaultRef: true, refName: "main" }), + currentBranch: () => Effect.succeed("main"), + invalidateRemoteStatus: () => Effect.void, remoteStatus: () => Effect.sync(() => ({ ...baseRemoteStatus, behindCount })), invalidateStatus: () => Effect.void, invalidateLocalStatus: () => Effect.void, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 5e31d96d1a8bf..c6a747105c03a 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -135,9 +135,13 @@ interface CachedValue { readonly value: T; } +interface CachedRemoteStatus extends CachedValue { + readonly branch: string | null; +} + interface CachedVcsStatus { readonly local: CachedValue | null; - readonly remote: CachedValue | null; + readonly remote: CachedRemoteStatus | null; } interface ActiveRemotePoller { @@ -271,106 +275,51 @@ export const make = Effect.gen(function* () { const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local }; + const [localChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + const remote = previous.remote?.branch === local.refName ? previous.remote : null; + return [ + [previous.local?.fingerprint !== nextLocal.fingerprint, previous.remote !== remote], + new Map(cache).set(cwd, { local: nextLocal, remote }), + ] as const; }); - - if (options?.publish && shouldPublish) { + if (options?.publish && remoteCleared) { yield* PubSub.publish(changesPubSub, { cwd, - event: { - _tag: "localUpdated", - local, - }, + event: { _tag: "remoteUpdated", remote: null }, + }); + } + if (options?.publish && localChanged) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { _tag: "localUpdated", local }, }); } - return local; }, ); const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + function* (cwd: string, remote: CachedRemoteStatus | null, options?: { publish?: boolean }) { + const [changed, value] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + const next = remote?.branch === previous.local?.value.refName ? remote : null; + return [ + [previous.remote?.fingerprint !== next?.fingerprint, next?.value ?? null], + new Map(cache).set(cwd, { ...previous, remote: next }), + ] as const; }); - - if (options?.publish && shouldPublish) { + if (options?.publish && changed) { yield* PubSub.publish(changesPubSub, { cwd, - event: { - _tag: "remoteUpdated", - remote, - }, + event: { _tag: "remoteUpdated", remote: value }, }); } - - return remote; + return value; }, ); - const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - local: nextLocal, - remote: nextRemote, - }); - return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, - nextCache, - ] as const; - }); - - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "snapshot", - local, - remote, - }, - }); - } - - return mergeGitStatusParts(local, remote); - }); - const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( cwd: string, ) { @@ -388,6 +337,14 @@ export const make = Effect.gen(function* () { return yield* loadLocalStatus(cwd); }); + const getStatusParts = Effect.fn("VcsStatusBroadcaster.getStatusParts")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return { local: cached.local.value, remote: cached.remote?.value ?? null }; + } + return { local: yield* loadLocalStatus(cwd), remote: null }; + }); + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( @@ -398,21 +355,16 @@ export const make = Effect.gen(function* () { if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } + yield* getOrLoadLocalStatus(cwd); return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { const latest = yield* getCachedStatus(cwd); const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); - const [local, remote] = yield* Effect.all( - [ - latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), - latest?.remote - ? Effect.succeed(latest.remote.value) - : workflow.remoteStatus({ cwd }, { refreshUpstream }), - ], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote); + const remote = latest?.remote ?? (yield* readRemoteStatus(cwd, { refreshUpstream })); + yield* updateCachedRemoteStatus(cwd, remote); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); }), ); }); @@ -432,11 +384,30 @@ export const make = Effect.gen(function* () { return yield* refreshLocalStatusCore(cwd); }); + const readRemoteStatus = Effect.fn("VcsStatusBroadcaster.readRemoteStatus")(function* ( + cwd: string, + options?: Parameters[1], + ) { + const branch = yield* workflow.currentBranch(cwd); + // GitManager's remote TTL cache is keyed by cwd, not the checked-out branch. + yield* workflow.invalidateRemoteStatus(cwd); + const value = yield* workflow.remoteStatus({ cwd }, options); + const currentBranch = yield* workflow.currentBranch(cwd); + const cached = yield* getCachedStatus(cwd); + if (branch !== currentBranch || cached?.local?.value.refName !== currentBranch) { + yield* refreshLocalStatusCore(cwd); + } + return branch === currentBranch + ? { branch, value, fingerprint: fingerprintStatusPart(value) } + : null; + }); + const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( cwd: string, - remote: VcsStatusRemoteResult | null, + remoteRead: CachedRemoteStatus | null, policyCwds: ReadonlyArray, ) { + const remote = remoteRead?.value ?? null; let pullAttempted = false; return yield* Effect.gen(function* () { const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { @@ -454,17 +425,21 @@ export const make = Effect.gen(function* () { yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); - if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; + if ( + !local.isRepo || + !local.isDefaultRef || + local.hasWorkingTreeChanges || + local.refName !== remoteRead?.branch + ) + return null; pullAttempted = true; yield* workflow.pullCurrentBranch(cwd); yield* workflow.invalidateStatus(cwd); - const [refreshedLocal, refreshedRemote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], - { concurrency: "unbounded" }, - ); - yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); - return { local: refreshedLocal, remote: refreshedRemote }; + yield* refreshLocalStatusCore(cwd); + const refreshedRemote = yield* readRemoteStatus(cwd, { refreshUpstream: false }); + yield* updateCachedRemoteStatus(cwd, refreshedRemote, { publish: true }); + return yield* getStatusParts(cwd); }).pipe( Effect.catch((error) => Effect.gen(function* () { @@ -473,11 +448,11 @@ export const make = Effect.gen(function* () { errorTag: error._tag, }); if (!pullAttempted) return null; - // Pull can change local state before failing; propagate reconciliation failures instead of publishing stale status. - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - yield* updateCachedStatus(cwd, local, remote, { publish: true }); - return { local, remote }; + // A pull or its follow-up reads can fail after local state has changed. + // Let a failed reconciliation reach the caller instead of publishing old status. + yield* refreshLocalStatusCore(cwd); + yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); + return yield* getStatusParts(cwd); }), ), ); @@ -493,15 +468,13 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; - const remote = yield* workflow.remoteStatus({ cwd }, options); + const remoteRead = yield* readRemoteStatus(cwd, options); + const remote = remoteRead?.value; const pulled = options?.refreshUpstream === false ? null - : yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + : yield* maybeAutoPull(cwd, remoteRead, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; if ( remote && @@ -512,7 +485,7 @@ export const make = Effect.gen(function* () { ) { yield* refreshLocalStatusCore(cwd); } - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + return yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); }), ); }); @@ -521,35 +494,41 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const previous = options?.localOnly ? yield* getCachedStatus(cwd) : null; + const local = yield* refreshLocalStatusCore(cwd); if (options?.localOnly) { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - const previous = yield* getCachedStatus(cwd); - if (previous?.local?.value.refName !== local.refName) { + if (previous?.local?.value.refName !== local.refName || !previous?.remote) { yield* workflow.invalidateRemoteStatus(cwd); - return yield* updateCachedStatus(cwd, local, null, { publish: true }); + yield* updateCachedRemoteStatus( + cwd, + { branch: local.refName, fingerprint: fingerprintStatusPart(null), value: null }, + { publish: true }, + ); } - yield* updateCachedLocalStatus(cwd, local, { publish: true }); - return mergeGitStatusParts(local, previous?.remote?.value ?? null); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); }), ); } - // Full invalidation also bypasses GitManager's slow PR cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); + // Bump the PR lookup epoch after earlier remote writers have finished. yield* workflow.invalidateStatus(cwd); - // Local after remote: the fetch can move the base that the Changes totals compare with. - const remote = yield* workflow.remoteStatus({ cwd }, { refreshUpstream }); + const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream }); + // Fetch can move the base used for local Changes totals. const local = yield* workflow.localStatus({ cwd }); + yield* updateCachedLocalStatus(cwd, local, { publish: true }); // Without a fetch, the cached behind count cannot justify an automatic pull. - const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remote, [rawCwd]) : null; + const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remoteRead, [rawCwd]) : null; if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); }), ); }); @@ -561,7 +540,7 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); - if (cached?.remote?.value == null) return null; + if (!cached?.local || cached.remote?.value === null) return null; const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; const shouldRefresh = (yield* Effect.forEach( @@ -571,12 +550,11 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRefresh) return null; - // Re-resolve the branch; cached PRs may belong to a checkout's previous branch. - const remote = yield* workflow.remoteStatus( - { cwd }, - { refreshUpstream: false, refreshMissingPullRequest: true }, - ); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + const remoteRead = yield* readRemoteStatus(cwd, { + refreshUpstream: false, + refreshMissingPullRequest: true, + }); + return yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); }), ); }); diff --git a/apps/server/src/ws.projects.test.ts b/apps/server/src/ws.projects.test.ts index 6a1cbb4c35a27..52c9352f893f8 100644 --- a/apps/server/src/ws.projects.test.ts +++ b/apps/server/src/ws.projects.test.ts @@ -45,6 +45,7 @@ import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; +import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ProviderMaintenanceRunner } from "./provider/providerMaintenanceRunner.ts"; @@ -150,6 +151,7 @@ const harness = ( Effect.succeed(id === testProjectId ? Option.some(project) : Option.none()), }), stub(ManagedProjectFolders.ManagedProjectFolders), + stub(ScratchThreadPromotion.ScratchThreadPromotion), stub(ThreadSearch.ThreadSearch), stub(ProviderSessionManager.ProviderSessionManagerV2), stub(ScheduledTasks.ScheduledTaskService), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fb3dbc2a6b573..1787e45040126 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -137,6 +137,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; import { projectMutationOperation } from "./project/ProjectMutation.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -865,6 +866,7 @@ const layerWsRpc = () => const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const scratchThreadPromotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; const threadSearch = yield* ThreadSearch.ThreadSearch; const mcpAppRequests = yield* McpAppRequests.McpAppRequests; @@ -1536,6 +1538,14 @@ const layerWsRpc = () => (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), ), + [WS_METHODS.projectsCreateFromThread]: (input) => + scratchThreadPromotion + .promote(input) + .pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), + ), + ), [WS_METHODS.projectCloneCancel]: (input) => projectCloneTracker.cancel(input.projectId).pipe(Effect.map((applied) => ({ applied }))), [WS_METHODS.projectCloneRetry]: (input) => diff --git a/apps/web/package.json b/apps/web/package.json index f100cc50900c5..2ece254d563d1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 11d839875a8fc..ea5aeda32c0c2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -65,6 +65,7 @@ import React, { Suspense, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, + type MouseEvent as ReactMouseEvent, use, memo, useLayoutEffect, @@ -762,6 +763,11 @@ function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { }; } +// Native word selection stops at hyphens and dots, so a double-click takes the whole span. +function selectInlineCode(event: ReactMouseEvent) { + window.getSelection()?.selectAllChildren(event.currentTarget); +} + const CHAT_MARKDOWN_COMPONENTS = { h1: markdownHeadingRenderer(1), h2: markdownHeadingRenderer(2), @@ -1119,6 +1125,11 @@ const CHAT_MARKDOWN_COMPONENTS = { inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } + return ( + + {children} + + ); } return ( diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index 87bcf8f2cfe42..a49fcb59a9955 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -12,8 +12,7 @@ const contextChipVariants = cva( variants: { kind: { neutral: "[--context-chip-accent:currentColor]", - image: - "rounded-lg border border-(--context-chip-accent)/30 bg-(--context-chip-accent)/10 px-1.5 [--context-chip-accent:var(--context-chip-image)]", + image: "[--context-chip-accent:var(--context-chip-image)]", video: "[--context-chip-accent:var(--context-chip-video)]", file: "[--context-chip-accent:var(--context-chip-file)]", mention: "[--context-chip-accent:var(--context-chip-mention)]", diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 4da83d4f8da76..4be40cba2a2e8 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,6 +6,7 @@ import { CheckIcon, ChevronDownIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, PilcrowIcon, Rows3Icon, @@ -30,6 +31,8 @@ import { useDiffPanelStore, } from "../diffPanelStore"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; +import { useHideTestFiles } from "./diffs/useHideTestFiles"; import { useRenderedAppearance } from "../hooks/useTheme"; import { useStableCallback } from "../hooks/useStableCallback"; import { @@ -220,6 +223,7 @@ export default function DiffPanel({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [baseRefQuery, setBaseRefQuery] = useState(""); const [codeViewRevision, setCodeViewRevision] = useState(0); const [diffFileExpansionErrors, setDiffFileExpansionErrors] = @@ -579,6 +583,16 @@ export default function DiffPanel({ const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; const renderableFileEntries = renderableFiles.map(getCachedFileEntry); const diffFileKeys = renderableFileEntries.map((file) => file.fileKey); + const testFileCount = renderableFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // Hiding is display-only: lazy loading indexes into the full list. A requested file stays visible. + const isFileShown = (file: FileDiffMetadata) => { + const path = resolveFileDiffPath(file); + return !hideTestFiles || path === selectedFilePath || !isTestChangedFile(path); + }; + const shownFileEntries = renderableFileEntries.filter(({ fileDiff }) => isFileShown(fileDiff)); + const shownFileKeys = shownFileEntries.map((file) => file.fileKey); const defaultCollapsedDiffFileKeys = settings.diffFilesCollapsed ? new Set(diffFileKeys) : EMPTY_COLLAPSED_DIFF_FILE_KEYS; @@ -601,7 +615,7 @@ export default function DiffPanel({ count={renderableFiles.length - settledFileCount} /> ) : null; - const codeViewFiles = renderableFileEntries + const codeViewFiles = shownFileEntries .filter( ({ fileDiff }) => !lazySource || !readyFilePaths || readyFilePaths.has(resolveFileDiffPath(fileDiff)), @@ -614,10 +628,12 @@ export default function DiffPanel({ collapsed: collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, })); - const pendingFiles = renderableFiles.filter((file) => file.cacheKey?.endsWith(":pending")); - const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const pendingFiles = renderableFiles.filter( + (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), + ); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(shownFileKeys, collapsedDiffFileKeys); const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); - const fileTreeEntries = diffFileTreeEntries(renderableFiles); + const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); const revealDiffFile = (filePath: string) => { const index = renderableFileEntries.findIndex( @@ -686,7 +702,7 @@ export default function DiffPanel({ const toggleDiffFileCollapse = () => { setCodeViewRevision((current) => current + 1); - updateCollapsedDiffFiles((current) => toggleAllDiffFiles(diffFileKeys, current)); + updateCollapsedDiffFiles((current) => toggleAllDiffFiles(shownFileKeys, current)); }; const selectTurn = (runId: RunId) => { @@ -1016,6 +1032,28 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {testFileCount > 0 && ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles + ? `Show ${testFileCount} hidden test ${testFileCount === 1 ? "file" : "files"}` + : "Hide test files"} + + + )} {diffFileKeys.length > 0 && ( void) | undefined; compact?: boolean; + showActions?: boolean; + showChanges?: boolean; onOpenChanges?: () => void; } @@ -926,6 +928,8 @@ export default function GitActionsControl({ draftId, onOpenFile, compact = false, + showActions = true, + showChanges = true, onOpenChanges, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); @@ -1456,7 +1460,7 @@ export default function GitActionsControl({ return ( <> - {!isRepo ? ( + {!showActions ? null : !isRepo ? ( )} - {isRepo && !quietEmptyDraft ? ( + {showChanges && isRepo && !quietEmptyDraft ? ( - + {/* Its discovery query should stop when the Git actions row that opens it is hidden. */} + {showActions ? ( + + ) : null} settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -2551,6 +2557,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), + canTurnIntoProject: canTurnThreadIntoProject(threadRef), supports: { settlement: configLoaded, autoSettleOptOut: supportsAutoSettleOptOut, @@ -2631,6 +2638,26 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "unpin": attemptUnpin(threadRef); return; + case "turn-into-project": { + const result = await turnThreadIntoProject(threadRef); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Project created", + description: thread.title, + }); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not turn thread into a project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "auto-settle:enabled": case "auto-settle:disabled": { const result = await setThreadAutoSettle( @@ -3159,7 +3186,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); const isCard = section === "active" || section === "pinned" || section === "working"; - const rowVariant = folderRow ? "folder" : isCard ? "card" : "slim"; + const rowVariant = folderRow + ? folderRowsMultiline + ? "folder-multiline" + : "folder" + : isCard + ? "card" + : "slim"; return ( - + ); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f7fbab1617b5c..23b3d356da4ce 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -228,7 +228,7 @@ import { type ThreadShell, } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { LatestProposedPlanState, PendingApproval, diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 697a0e0bfd6f2..e3e14c4a84005 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -3,10 +3,8 @@ import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; -import { - derivePendingUserInputProgress, - type PendingUserInputDraftAnswer, -} from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; diff --git a/apps/web/src/components/chat/MarkdownExternalLink.tsx b/apps/web/src/components/chat/MarkdownExternalLink.tsx index b3efae29406bf..9b4618b8f3f65 100644 --- a/apps/web/src/components/chat/MarkdownExternalLink.tsx +++ b/apps/web/src/components/chat/MarkdownExternalLink.tsx @@ -54,13 +54,19 @@ function leadingExternalLinkTextLength(text: string): number { return Math.min(text.length, 1); } +// Long words such as URLs can break after any character so they fit narrow +// table columns. Short ones, like a PR number, stay whole. function breakableExternalLinkText(text: string): ReactNode[] { - return Array.from(text, (character, index) => ( - - {character} - - - )); + return Array.from(text.matchAll(/\s+|\S+/g), ({ 0: word, index: start }) => + word.length <= 16 + ? word + : Array.from(word, (character, offset) => ( + + {character} + + + )), + ); } export function MarkdownExternalLinkContent({ diff --git a/apps/web/src/components/chat/MarkdownTable.tsx b/apps/web/src/components/chat/MarkdownTable.tsx index 9859ff97ad5eb..ee1e1825adcd2 100644 --- a/apps/web/src/components/chat/MarkdownTable.tsx +++ b/apps/web/src/components/chat/MarkdownTable.tsx @@ -1,8 +1,7 @@ -import { Check, Copy, Maximize2, Minimize2 } from "lucide"; -import { useRef, useState, type ComponentProps } from "react"; +import { Check, Copy } from "lucide"; +import { useRef, type ComponentProps } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { getClientSettings } from "../../hooks/useSettings"; import { serializeTableElementToCsv, serializeTableElementToMarkdown, @@ -16,13 +15,11 @@ import { reportMarkdownActionFailure } from "./markdownActionFailure"; export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { const containerRef = useRef(null); - const [expanded, setExpanded] = useState(() => getClientSettings().wordWrap); const { copyToClipboard, isCopied: copied } = useCopyToClipboard<"markdown" | "csv">({ timeout: 1200, onError: (cause, format) => reportMarkdownActionFailure({ operation: "copy-table", format }, cause), }); - const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; const copyLabel = copied ? "Copied" : "Copy table"; const handleCopy = (format: "markdown" | "csv") => { @@ -37,33 +34,12 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { }; return ( -
+
{children}
{/* Stays within the row's px-1: the chat lane clips sideways overflow. */} -
- - setExpanded((value) => !value)} - aria-label={expandLabel} - /> - } - > - - - {expandLabel} - +
{ }); describe("failed turn transcript", () => { - it.each(["provider_error", "usage_limit"] as const)( - "keeps historical %s failures and preceding work visible without disclosures", - (failureClass) => { + it.each([ + { position: "historical", failureClass: "provider_error" }, + { position: "historical", failureClass: "usage_limit" }, + { position: "latest", failureClass: "usage_limit" }, + ] as const)( + "keeps $position $failureClass failures visible with preceding work grouped appropriately", + ({ position, failureClass }) => { + const isLatest = position === "latest"; + const commands = isLatest ? ["pwd", "ls", "false"] : ["pwd"]; const runId = RunId.make("failed-run"); const threadId = ThreadId.make("failed-thread"); const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); @@ -3880,23 +3886,24 @@ describe("failed turn transcript", () => { attachments: [], }, }, - { - position: 1, + ...commands.map((input, index): OrchestrationV2ProjectedTurnItem => ({ + position: index + 1, visibility: "local", sourceThreadId: threadId, - sourceItemId: TurnItemId.make("command"), + sourceItemId: TurnItemId.make(index === 0 ? "command" : `command-${index}`), item: { ...base, - id: TurnItemId.make("command"), + id: TurnItemId.make(index === 0 ? "command" : `command-${index}`), + ordinal: index + 1, type: "command_execution", status: "completed", - input: "pwd", + input, output: "", exitCode: 0, }, - }, + })), { - position: 2, + position: commands.length + 1, visibility: "local", sourceThreadId: threadId, sourceItemId: TurnItemId.make("failure"), @@ -3918,20 +3925,24 @@ describe("failed turn transcript", () => { timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items, optimisticMessages: [], - }).map((entry) => ({ - ...entry, - attempt: { - id: RunAttemptId.make("superseded-attempt"), - runId, - attemptOrdinal: 1, - rootNodeId: NodeId.make("superseded-root"), - status: "superseded" as const, - completedAt: null, - }, - })), + }).map((entry) => + isLatest + ? entry + : { + ...entry, + attempt: { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + completedAt: null, + }, + }, + ), latestRun: { - runId: RunId.make("newer-run"), - status: "completed", + runId: isLatest ? runId : RunId.make("newer-run"), + status: isLatest ? "failed" : "completed", startedAt: DateTime.formatIso(at), completedAt: DateTime.formatIso(at), }, @@ -3939,16 +3950,19 @@ describe("failed turn transcript", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect( - rows.some( - (row) => - row.kind === "turn-fold" || row.kind === "attempt-fold" || row.kind === "work-toggle", - ), - ).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold" || row.kind === "attempt-fold")).toBe( + false, + ); const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : [])); - expect(work.map((entry) => entry.id)).toEqual(["command", "failure"]); + expect(rows.find((row) => row.kind === "work-toggle")).toEqual( + isLatest ? expect.objectContaining({ hiddenCount: 3, expanded: false }) : undefined, + ); + expect(work.map((entry) => entry.id)).toEqual( + isLatest ? ["failure"] : ["command", "failure"], + ); expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later."); expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z"); + if (isLatest) return; const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"), optimisticMessages: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4ea9e852edfba..c26bcf51a718a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -973,6 +973,24 @@ function failedTimelineRunIds( return failed; } +/** Usage-limit pauses keep their tool groups; any other failure itemizes the run's work. */ +function itemizedFailedRunIds( + entries: ReadonlyArray, + failedRunIds: ReadonlySet, +) { + const usageLimited = new Set(); + const otherFailures = new Set(); + for (const entry of entries) { + const item = timelineEntryFailedItem(entry); + if (item?.runId) { + (item.failure.class === "usage_limit" ? usageLimited : otherFailures).add(item.runId); + } + } + return new Set( + [...failedRunIds].filter((runId) => !usageLimited.has(runId) || otherFailures.has(runId)), + ); +} + function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; @@ -1410,6 +1428,7 @@ export function deriveMessagesTimelineRows(input: { const nextRows: MessagesTimelineRow[] = []; const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( @@ -1436,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyEntryIds: completeSupersededReplyIds, }); + const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry])); const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1681,14 +1701,14 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { - const deferredWorkItemIds = timelineEntries.flatMap((entry) => - turnFold.entryIds.has(entry.id) && - entry.kind === "work" && - entry.entry.projectedItem?.item.deferredWork && - !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) + const deferredWorkItemIds = [...turnFold.entryIds].flatMap((entryId) => { + const entry = timelineEntryById.get(entryId); + return entry?.kind === "work" && + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) ? [String(entry.entry.projectedItem.sourceItemId)] - : [], - ); + : []; + }); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, @@ -1771,7 +1791,7 @@ export function deriveMessagesTimelineRows(input: { if (timelineEntry.kind === "work") { if ( isPendingApproval(timelineEntry.entry) || - (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || + (timelineEntry.entry.runId != null && itemizedRunIds.has(timelineEntry.entry.runId)) || (timelineEntry.entry.itemType === "error" && timelineEntry.entry.toolLifecycleStatus === "failed") || timelineEntry.entry.tone === "error" || @@ -1797,6 +1817,8 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.sourceActivityKind === "context-compaction" || + (nextEntry.entry.itemType === "error" && + nextEntry.entry.toolLifecycleStatus === "failed") || nextEntry.entry.tone === "error" || nextEntry.entry.sourceActivityKind === "runtime.error" || nextEntry.entry.itemType === "system_notice" || diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8f830c3393c46..a9cab1554a3d3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1221,7 +1221,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ >
@@ -1241,10 +1241,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ {unmatchedPendingApprovals.size > 0 ? (
-
+
{[...unmatchedPendingApprovals.values()].map((approval) => ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index b7430716f96f9..c5a8cdf64c18f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -41,6 +41,7 @@ import { } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { registerModelPickerContent } from "../../modelPickerVisibility"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, @@ -377,6 +378,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [searchQuery, setSearchQuery] = useState(""); const modelListRef = useRef(null); const pickerContentRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes this picker's mount to global keydown handlers + useLayoutEffect(() => registerModelPickerContent(), []); const highlightedModelKeyRef = useRef(null); const { favorites } = useEnvironmentModelPreferences(props.environmentId); const activeEntry = props.instanceEntries.find( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 20ecde3272276..ca2497bd20ee8 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -153,12 +153,6 @@ export function ThreadAutomationsPanel(props: { } > - {tasksQuery.error !== null ? ( -

- Could not load automations: {tasksQuery.error} -

- ) : null} -
    {boundTasks.map((task) => (
  • ))}
+ {/* Kept last: the details card treats a section whose last child is empty as empty. */} + {tasksQuery.error !== null ? ( +

+ Could not load automations: {tasksQuery.error} +

+ ) : null} ); } diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx index 37110875ed0ea..a8728d7879dd4 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -42,10 +42,13 @@ function Card({ threadRef, inline, fixture, + rows = 1, }: { threadRef: ScopedThreadRef; inline: boolean; fixture: ReturnType; + /** Rows of 100px shown at full density. Compact always takes 50px. */ + rows?: number; }) { return ( // The web root renders in StrictMode, which re-runs ref callbacks on every mount. @@ -63,8 +66,17 @@ function Card({ detailsCardPlacement: inline ? placement : null, }} > - - {() =>

Details for {threadRef.threadId}

} + + {(density) => ( +

+ Details for {threadRef.threadId} +

+ )}
@@ -136,3 +148,13 @@ it("reopens after a normal close", async () => { await setOpen(true); await expect.element(page.getByText("Details for thread-b")).toBeVisible(); }); + +it("returns to full density when hidden items make the content fit", async () => { + using fixture = setup(); + await using screen = await render(); + const card = page.getByRole("region", { name: "Thread details" }); + await expect.element(card).toHaveAttribute("data-density", "compact"); + + await screen.rerender(); + await expect.element(card).toHaveAttribute("data-density", "full"); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4add56b80eb08..a5df136f14615 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -1,5 +1,11 @@ import { workspaceAreaProps } from "../../lib/workspaceFocus"; -import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { + useLayoutEffect, + useState, + type MouseEventHandler, + type ReactNode, + type RefObject, +} from "react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../../lib/utils"; @@ -17,11 +23,16 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, + contentKey, + onContextMenu, children, }: { threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; + /** Changes when the set of rendered items changes, so cached heights are measured again. */ + contentKey: string; + onContextMenu?: MouseEventHandler; children: (density: "full" | "compact" | "essential") => ReactNode; }) { const detailsArea = workspaceAreaProps("details", 3, "Thread details"); @@ -36,7 +47,7 @@ export function ThreadDetailsCard({ const popoverOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"), ); - const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`; + const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}:${contentKey}`; const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, @@ -109,6 +120,7 @@ export function ThreadDetailsCard({ "max-h-[min(calc(100dvh-6.5rem),calc(var(--available-height,100dvh)-1rem))]", )} style={placement ? { maxHeight: height } : undefined} + onContextMenu={onContextMenu} data-thread-details-card > diff --git a/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx new file mode 100644 index 0000000000000..d84c13809eaf6 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx @@ -0,0 +1,45 @@ +import { + getClientSettings, + updateClientSettings, + useClientSettings, +} from "../../hooks/useSettings"; +import { MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator } from "../ui/menu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + setThreadDetailsItem, + THREAD_DETAILS_ITEMS, +} from "./threadDetailsItems"; + +/** Menu body that shows and hides thread details items. The card's context menu and Settings share it. */ +export function ThreadDetailsItemsMenuItems() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + return ( + <> + + Show in thread details + {THREAD_DETAILS_ITEMS.map((item) => ( + + updateClientSettings({ + threadDetailsItems: setThreadDetailsItem(getClientSettings(), item, checked), + }) + } + > + {item.label} + + ))} + + + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + > + Reset to default + + + ); +} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 59ab660b710a6..aa569c939f413 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -15,15 +15,26 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { memo, type ComponentProps } from "react"; +import { memo, useState, type ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; +import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; +import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; +import { useClientSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { SlidersHorizontalIcon } from "lucide-react"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, +} from "./threadDetailsPanelStyles"; +import { Menu, MenuPopup } from "../ui/menu"; // Coder: there is no local editor to open, and the gateway installs the helper matching this // client, so there is no version mismatch warning. Repository actions come from the helper's -// validated t3.json read by project. +// validated t3.json read by project. No dev-servers or usage-limits items either. const GitActionsControl = memo(BaseGitActionsControl); interface ThreadDetailsPanelProps extends Pick< @@ -69,8 +80,10 @@ interface ThreadDetailsPanelProps extends Pick< } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + const visible = useClientSettings(resolveThreadDetailsVisibility); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); const projectFile = useT3ProjectFile( - props.activeProjectScripts && props.activeProjectId + props.activeProjectScripts && visible.scripts && props.activeProjectId ? { environmentId: props.environmentId, projectId: props.activeProjectId } : null, ); @@ -107,82 +120,163 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} + contentKey={THREAD_DETAILS_ITEMS.filter((item) => visible[item.id]) + .map((item) => item.id) + .join(",")} + onContextMenu={(event) => { + // Popups the card opens bubble here through React; they, fields, links, and selected text + // keep their own menus. + const target = event.target; + const selection = window.getSelection(); + if ( + !(target instanceof Element) || + !event.currentTarget.contains(target) || + target.closest("input, textarea, [contenteditable], a[href]") || + (selection && + !selection.isCollapsed && + (event.currentTarget.contains(selection.anchorNode) || + event.currentTarget.contains(selection.focusNode))) + ) + return; + event.preventDefault(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }} > - {(density) => ( - <> - -
- {density === "full" ? ( - { + // Section flags follow the rows that will render, so no empty section steals the divider rule. + const show = { + environment: density === "full" && visible.environment, + scripts: visible.scripts && props.activeProjectScripts !== undefined, + branch: props.gitCwd !== null && props.isGitRepo && visible.branch, + git: + props.gitCwd !== null && + props.activeProjectName !== undefined && + (visible.gitActions || (visible.changes && props.isGitRepo)), + }; + return ( + <> +
+ {show.environment || show.scripts ? ( + +
+ {show.environment ? ( + + ) : null} + + {show.scripts && props.activeProjectScripts ? ( + + ) : null} +
+
+ ) : null} + + {props.gitCwd && (show.branch || show.git) ? ( + +
+ {show.branch ? ( + + ) : null} + {show.git ? ( + + ) : null} +
+
+ ) : null} + + {/* Coder: no dev-servers section; it needs upstream's preview port discovery. */} + {density === "full" && !props.draftId && visible.automations ? ( + ) : null} - {props.activeProjectScripts ? ( - ) : null} -
- - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} + {/* Shown only when no section above has content, so an emptied card can be refilled. */} +
+ { + const rect = event.currentTarget.getBoundingClientRect(); + setMenuPosition({ x: rect.left, y: rect.bottom }); + }} + > + + + Choose what to show + +
- - ) : null} - - {/* Coder: no dev-servers section; it needs upstream's preview port discovery. */} - {density === "full" && !props.draftId ? ( - - ) : null} +
- {density === "full" && !props.draftId ? ( - - ) : null} - - )} + !open && setMenuPosition(null)} + > + + new DOMRect(menuPosition.x, menuPosition.y, 0, 0), + } + : undefined + } + align="start" + side="bottom" + > + + + + + ); + }} ); } diff --git a/apps/web/src/components/chat/ThreadDetailsSection.tsx b/apps/web/src/components/chat/ThreadDetailsSection.tsx index 00aacd5a7b152..de5ce6c63825d 100644 --- a/apps/web/src/components/chat/ThreadDetailsSection.tsx +++ b/apps/web/src/components/chat/ThreadDetailsSection.tsx @@ -22,7 +22,12 @@ export function ThreadDetailsSection({ {...props} aria-labelledby={showHeading ? headingId : undefined} aria-label={showHeading ? undefined : title} - className={cn("px-2 pt-2 pb-2.5", separated && "border-t border-border/65")} + // A row can still render nothing (a fresh draft quiets Git actions), so an empty section + // collapses, and a divider only follows a section that has content. + className={cn( + "px-2 pt-2 pb-2.5 has-[>div:last-child:empty]:hidden", + separated && "border-border/65 [section:not(:has(>div:last-child:empty))~&]:border-t", + )} >
+ + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(parentRef), + }) + } + /> + } + > + + + + {title} + + + {label ? ( + on {label} + ) : null} + + + {environment === null + ? `Delegated from ${label ?? "a linked environment"}. Connect to it to open the parent thread.` + : "Open parent agent"} + + + + ); +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -412,7 +471,15 @@ export function ThreadRelationshipsPanel(props: { (agent) => agent.childThreadId === null && agent.status === "running", ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; - if (relationshipRows.length === 0 && runningCount === 0 && remoteSubagents.length === 0) { + // A linked environment's agent may have delegated this thread from there. + const delegatedFrom = currentThread?.delegatedFrom; + + if ( + relationshipRows.length === 0 && + runningCount === 0 && + remoteSubagents.length === 0 && + delegatedFrom === undefined + ) { return null; } @@ -487,7 +554,13 @@ export function ThreadRelationshipsPanel(props: { headingId="thread-details-lineage-heading" title={runningCount > 0 ? `Lineage · ${runningCount} running` : "Lineage"} // A lone "Previous agents" row names itself; the heading returns for status or actions. - showHeading={runningCount > 0 || canDetach || related.length > 0 || active.length > 0} + showHeading={ + runningCount > 0 || + canDetach || + related.length > 0 || + active.length > 0 || + delegatedFrom !== undefined + } data-thread-relationships-panel actions={ canDetach ? ( @@ -515,6 +588,14 @@ export function ThreadRelationshipsPanel(props: { ) : null } > + {delegatedFrom === undefined ? null : ( + {}}> + + + )} {groups.map((group) => ( {(visibleRows) => diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index c0ef860173cf3..afc829af0422f 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -101,6 +101,7 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ environmentId={ctx.activeThreadEnvironmentId} providerInstanceId={item.providerInstanceId} driver={item.driver} + remote={item.remoteChild} status={agents.length === 1 ? status : undefined} /> ))} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f02780bed3af1..79a29d293dc49 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -616,9 +616,12 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : null; // Workflow levels such as Ultracode always show their name; the setting names every level. + // Traits the bars cannot show, such as an agent mode, appear as words in the composer. const effortWord = effortGlyph ? (effortGlyph.special ?? (effortDisplay === "label" ? effortGlyph.label : null)) - : null; + : isComposerOwned + ? triggerLabel + : null; return ( ) : null} + ) : effortWord !== null ? ( + <> + {effortWord} +
} /> + + {/* Coder: no guest links row; guest links need T3 Connect, which is not carried. */} + {/* Coder: no Simlock hosts row; device hosts are not carried. */} @@ -42,6 +44,26 @@ function BoringSidebarRow() { ); } +function BoringSidebarMultilineRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ boringSidebarMultilineRows: Boolean(checked) }) + } + aria-label="Two-line folder rows" + /> + } + /> + ); +} + function LinkedEnvironmentsRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); diff --git a/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx new file mode 100644 index 0000000000000..a142155d0357d --- /dev/null +++ b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx @@ -0,0 +1,53 @@ +import { ChevronDownIcon } from "lucide-react"; + +import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; +import { ThreadDetailsItemsMenuItems } from "../chat/ThreadDetailsItemsMenu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + THREAD_DETAILS_ITEMS, +} from "../chat/threadDetailsItems"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +export function ThreadDetailsItemsRow() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + const enabled = THREAD_DETAILS_ITEMS.filter((item) => visibility[item.id]).length; + return ( + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + /> + ) + } + control={ + + + } + > + {enabled} of {THREAD_DETAILS_ITEMS.length} on + + + + + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/TypographySettings.tsx b/apps/web/src/components/settings/TypographySettings.tsx index a4380a3e0bdc9..9e3c58aea1c60 100644 --- a/apps/web/src/components/settings/TypographySettings.tsx +++ b/apps/web/src/components/settings/TypographySettings.tsx @@ -235,7 +235,7 @@ function WordWrapRow() { return ( updateSettings({ wordWrap: Boolean(checked) })} - aria-label="Wrap code, tables, diffs, and file previews by default" + aria-label="Wrap code, diffs, and file previews by default" /> } /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e52b1baa054b2..3dc8fce1e565a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -294,6 +294,15 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "thread-details-items", + title: "Thread details", + to: "/settings/appearance", + // Coder: no editor, servers, or usage items. + searchTerms: [ + "customize show hide panel card environment scripts branch pull request git changes automations lineage", + ], + }, { id: "effort-display", title: "Effort display", @@ -359,7 +368,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "word-wrap", title: "Word wrap", to: "/settings/appearance", - searchTerms: ["long lines code blocks tables diffs file previews"], + searchTerms: ["long lines code blocks diffs file previews"], }, { id: "composer-context", @@ -410,6 +419,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta folders project tree legacy old classic sidebar"], }, + { + id: "boring-sidebar-multiline", + title: "Two-line folder rows", + to: "/settings/labs", + searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], + }, // Coder: no guest links entry; guest links are not carried. { id: "linked-environments-beta", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index a8da108dda96a..5a91a335161d5 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -211,18 +211,14 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ) : null} )} + ); }); -export const SidebarChromeFooter = memo(function SidebarChromeFooter({ - showUndoNotice = false, -}: { - showUndoNotice?: boolean; -}) { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - {showUndoNotice ? : null} diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 30a6776837eeb..104bda5582b4d 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,7 +1,7 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { type ReactNode } from "react"; +import { type ReactNode, useLayoutEffect } from "react"; import { cn } from "~/lib/utils"; import { animateSidebarLayoutChanges, @@ -9,19 +9,25 @@ import { type SidebarListMarker, } from "../Sidebar.logic"; import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; -import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; function SortableSidebarMarker(props: { marker: SidebarListMarker; className?: string; children?: ReactNode; + revealOverflow?: boolean; "data-testid"?: string; }) { - const { setNodeRef, transform, transition } = useSortable({ + const { node, setNodeRef, transform, transition } = useSortable({ id: sidebarMarkerId(props.marker), disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, animateLayoutChanges: animateSidebarLayoutChanges, }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the projected drop box inside the scroll viewport as drag transforms move it + useLayoutEffect(() => { + if (props.revealOverflow && transform?.y !== undefined) { + node.current?.firstElementChild?.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + }, [node, props.revealOverflow, transform?.y]); return (
  • {props.showHint ? (
    void }; - /** The first shelf at the bottom of the list hosts the undo notice just above itself. */ - hostsUndoNotice?: boolean; }) { const shelf = props.marker === "working-header" @@ -124,9 +129,6 @@ export function SidebarSectionHeader(props: { data-testid={`sidebar-${props.marker}`} className={cn("relative mx-0.5 h-8", props.className)} > - {props.hostsUndoNotice ? ( - - ) : null} + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + {props.scratchMachineLabel} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{formatDiffCount(diff.insertions)}{" "} + −{formatDiffCount(diff.deletions)} + + ) : null} + + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + + +
    + ); + const titleLineProviderStack = metaLineEmpty ? ( + + + + ) : null; + if (variant !== "card") { - const inFolder = variant === "folder"; + const multiline = variant === "folder-multiline"; + const inFolder = variant === "folder" || multiline; return (
  • @@ -1142,7 +1212,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP aria-busy={isRegeneratingTitle || undefined} className={cn( rowSurfaceClassName, - "flex h-9 items-center gap-2.5 px-2.5", + multiline + ? "flex flex-col justify-center px-2.5 py-1.5" + : "flex h-9 items-center gap-2.5 px-2.5", // Lines titles up with the folder name, past its chevron and icon. inFolder && "ps-9", )} @@ -1153,142 +1225,147 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP /> } > - {accessibleTitle} - {inFolder ? null : ( - - {props.project ? ( - - ) : null} - - )} - {draftIndicator} - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {sortable?.isDragging ? ( - dragDestination - ) : ( - + {/* `contents` keeps one-line rows a single flex row. */} +
    + {accessibleTitle} + {inFolder ? null : ( - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {/* The clock tells a wake time from an age at a glance. */} - - {props.snoozeWakeLabelText} - - ) : (inFolder ? isWokeStatus : isWoke) ? ( + {props.project ? ( + + ) : null} + + )} + {draftIndicator} + {title} + {multiline ? titleLineProviderStack : null} + {pinIndicator} + {multiline ? null : terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {sortable?.isDragging ? ( + dragDestination + ) : ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {/* The clock tells a wake time from an age at a glance. */} + + {props.snoozeWakeLabelText} + + ) : (inFolder ? isWokeStatus : isWoke) ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : inFolder && topStatus ? ( + // Folder rows have no status line, so the status takes the time slot. + + + {topStatus.label} + + ) : ( + + + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - Woke - + aria-label="Un-settle thread" + onClick={handleUnsettleClick} + onPointerDown={handleActionPointerDown} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Un-settle thread - ) : inFolder && topStatus ? ( - // Folder rows have no status line, so the status takes the time slot. - - - {topStatus.label} - ) : ( - - - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} + )} + + )} + {props.sweepAction !== null ? dragDestination : null} +
    + {multiline ? metaLine : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1297,16 +1374,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); } - const diff = latestRunDiff(thread); - // Only the provider icon would remain, so the row drops the line instead of leaving it blank. - const metaLineEmpty = - !thread.branch && - !showsScratchMachine && - prBadge === null && - diff === null && - !isRemote && - runningTerminalIds.length === 0; - return (
  • ) : null} - {metaLineEmpty ? ( - - - - ) : null} + {titleLineProviderStack}
  • - {metaLineEmpty ? null : ( -
    - {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - - {props.scratchMachineLabel} - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - - - ) : null} - - {isRemote && !showsScratchMachine ? ( - - - - ) : null} - - -
    - )} + {metaLine}
    {props.jumpLabel ? : null} diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx index 72b5754a1936d..9cf7dc9e2ef53 100644 --- a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx @@ -3,18 +3,24 @@ import { useState } from "react"; import { threadUndoLabel, - threadUndoNoticeSummary, undoLatestThreadAction, useThreadUndoNotice, } from "../../hooks/showThreadUndoNotice"; import { cn } from "../../lib/utils"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { InlineButton } from "../ui/button"; +import { SidebarMenuItem } from "../ui/sidebar"; -export function SidebarThreadUndoNotice({ className }: { className?: string }) { +/** Fills the free space in the sidebar footer's utility row, so it never moves or covers the list. */ +export function SidebarThreadUndoNotice() { const notice = useThreadUndoNotice((state) => state.notice); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const summary = notice ? threadUndoNoticeSummary(notice) : null; + // Short enough to fit beside the utility icons; screen readers hear the full summary. + const summary = notice + ? notice.count === 1 + ? notice.action + : `${notice.action} ${notice.count}` + : null; // Keeps the last text on screen while it fades out, then unmounts on transitionend. const [shownSummary, setShownSummary] = useState(summary); @@ -23,21 +29,23 @@ export function SidebarThreadUndoNotice({ className }: { className?: string }) { if (!text) return null; return ( -
    { - if (!summary && event.target === event.currentTarget) setShownSummary(null); - }} - className={cn( - "flex gap-1 px-2 text-2xs leading-6 text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", - !summary && "opacity-0", - className, - )} - > - {text}, - {threadUndoLabel(keybindings)} -
    + // No min-w-0: the undo button sets this item's minimum, so a shared row squeezes its neighbour. + +
    { + if (!summary && event.target === event.currentTarget) setShownSummary(null); + }} + // Reversed wrap: a summary that does not fit beside the undo button drops to a + // second line that overflow hides, instead of truncating mid-word. Browse mode + // reads the button first; the live announcement carries the full sentence. + className={cn( + "flex h-6 min-w-0 flex-1 flex-row-reverse flex-wrap gap-x-1 overflow-hidden text-2xs leading-6 whitespace-nowrap text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", + !summary && "opacity-0", + )} + > + {threadUndoLabel(keybindings)} + {text}, +
    +
    ); } diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 039c684dcdc36..bd9dfdcdcc4b6 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,10 +65,13 @@ describe("buildThreadActionMenuItems", () => { }, ); - it("disables archive while the thread is running", () => { - const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( - (item) => item.id === "archive", - ); - expect(archiveItem?.disabled).toBe(true); + it("disables archive and turning into a project while the thread is running", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + isRunning: true, + canTurnIntoProject: true, + }); + expect(items.find((item) => item.id === "archive")?.disabled).toBe(true); + expect(items.find((item) => item.id === "turn-into-project")?.disabled).toBe(true); }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index b2bfc4647d9d1..339420b92e877 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -4,6 +4,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" export type ThreadActionMenuId = | "new-thread-on-branch" | "filter-by-project" + | "turn-into-project" | "project-settings" | "pin" | "unpin" @@ -87,6 +88,8 @@ export interface ThreadActionMenuState { readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + /** A No project thread with its own folder, on a server that can promote it. */ + readonly canTurnIntoProject?: boolean; } /** Local navigation, read markers, and copying remain available to read-only clients. */ @@ -176,6 +179,16 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.canTurnIntoProject + ? [ + { + id: "turn-into-project" as const, + label: "Turn into project", + icon: "folder-plus", + disabled: state.isRunning, + }, + ] + : []), ...(state.supports.autoSettleOptOut ? [ { diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index e96a3fb52794a..12774bae205c0 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { listContinuationForEnter, nextOrderedMarkerText } from "./composer-list-continuation"; +import { + listContinuationForEnter, + listIndentForTab, + nextOrderedMarkerText, +} from "./composer-list-continuation"; function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); @@ -31,6 +35,25 @@ describe("composer list continuation", () => { expect(applyEdit("- [ ] ", listContinuationForEnter("- [ ] ", 6)!)).toBe(""); }); + it("lifts an empty nested item one level per press before leaving the list", () => { + let value = "1. parent\n 1. child\n 1. "; + for (const expected of [ + "1. parent\n 1. child\n 2. ", + "1. parent\n 1. child\n2. ", + "1. parent\n 1. child\n", + ]) { + value = applyEdit(value, listContinuationForEnter(value, value.length)!); + expect(value).toBe(expected); + } + }); + + it("renumbers the parent list after lifting an empty nested item", () => { + const value = "1. a\n 1. \n2. b"; + const edit = listContinuationForEnter(value, value.indexOf("\n2."))!; + expect(applyEdit(value, edit)).toBe("1. a\n2. \n3. b"); + expect(edit.cursorAfter).toBe("1. a\n2. ".length); + }); + it("refuses to split a supplementary currency skill chip", () => { const value = "1. 𑿝review go"; const cursor = value.indexOf(" go") - 1; @@ -44,6 +67,27 @@ describe("composer list continuation", () => { }); }); +describe("composer list indentation", () => { + // regression: #17124 Tab must nest a numbered item at its parent's content column + it.each([ + ["1. parent\n2. child", "1. parent\n 1. child"], + ["10. parent\n11. child", "10. parent\n 1. child"], + ["1.\tparent\n2. child", "1.\tparent\n 1. child"], + ['1. @"file\n 9. name"\n2. child', '1. @"file\n 9. name"\n 1. child'], + ])("nests %j as a first child and keeps the caret on its text", (value, expected) => { + const cursor = value.lastIndexOf("child") + 2; + const edit = listIndentForTab(value, cursor, cursor)!; + expect(applyEdit(value, edit)).toBe(expected); + expect(edit.cursorAfter).toBe(expected.lastIndexOf("child") + 2); + }); + + it("continues the numbering of an existing nested sibling", () => { + const value = "1. parent\n 1. first\n2. second"; + const edit = listIndentForTab(value, value.length, value.length)!; + expect(applyEdit(value, edit)).toBe("1. parent\n 1. first\n 2. second"); + }); +}); + describe("nextOrderedMarkerText", () => { it.each([ ["1.", "2."], diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 1c2de5a482433..583cf1bd04a92 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -13,39 +13,60 @@ type ListMarker = | { kind: "task"; indent: string; space: string } | { kind: "bullet"; indent: string; bullet: string; space: string }; -function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null { +interface ParsedListMarker { + marker: ListMarker; + markerEnd: number; + /** Column a nested item must reach to belong to this item. */ + contentColumn: number; +} + +function parseListMarker(line: string): ParsedListMarker | null { const indent = line.match(/^[ \t]*/)?.[0] ?? ""; const rest = line.slice(indent.length); const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/); if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) { + const space = ordered[3] ?? ""; return { marker: { kind: "ordered", indent, numberText: ordered[1], delimiter: ordered[2] === ")" ? ")" : ".", - space: ordered[3] ?? "", + space, }, markerEnd: indent.length + ordered[0].length, + contentColumn: contentColumn(indent, ordered[1].length + 1, space), }; } - const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/); - if (task) { + // The checkbox is item content, so a task's content starts after "- ". + const task = rest.match(/^-(\s)\[[ xX]\]((?:[ \t]+)|\s*$)/); + if (task && task[1] !== undefined) { return { - marker: { kind: "task", indent, space: task[1] ?? "" }, + marker: { kind: "task", indent, space: task[2] ?? "" }, markerEnd: indent.length + task[0].length, + contentColumn: contentColumn(indent, 1, task[1]), }; } const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/); if (bullet && bullet[1] !== undefined) { + const space = bullet[2] ?? ""; return { - marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" }, + marker: { kind: "bullet", indent, bullet: bullet[1], space }, markerEnd: indent.length + bullet[0].length, + contentColumn: contentColumn(indent, 1, space), }; } return null; } +// CommonMark content follows 1-4 columns of padding; wider padding is code, so the content +// column sits one column past the marker. +function contentColumn(indent: string, markerWidth: number, padding: string): number { + const markerEnd = indentWidth(indent) + markerWidth; + const space = indentWidth(padding, markerEnd); + return markerEnd + (space >= 1 && space <= 4 ? space : 1); +} + export function nextOrderedMarkerText(marker: string): string { const numberText = marker.slice(0, -1); const delimiter = marker.slice(-1); @@ -67,14 +88,14 @@ function nextMarkerText(marker: ListMarker): string { } // Markdown tabs advance to the next four-column stop. -function indentWidth(line: string): number { - let width = 0; +function indentWidth(line: string, fromColumn = 0): number { + let column = fromColumn; for (const char of line) { - if (char === " ") width += 1; - else if (char === "\t") width += 4 - (width % 4); + if (char === " ") column += 1; + else if (char === "\t") column += 4 - (column % 4); else break; } - return width; + return column - fromColumn; } function renumberFollowingItems( @@ -149,6 +170,55 @@ function currentLine(value: string, cursor: number): { start: number; end: numbe return { start, end, text: value.slice(start, end) }; } +function* listMarkersAbove( + value: string, + lineStart: number, + tokens: ReturnType, +): Generator { + let tokenIndex = tokens.length - 1; + while (lineStart > 0) { + const lineEnd = lineStart - 1; + lineStart = value.lastIndexOf("\n", lineEnd - 1) + 1; + // A newline inside a chip does not start a list item. + for (let token = tokens[tokenIndex]; token; token = tokens[tokenIndex]) { + if (token.start >= lineStart) tokenIndex -= 1; + else if (token.end > lineStart) lineStart = value.lastIndexOf("\n", token.start - 1) + 1; + else break; + } + const parsed = parseListMarker(value.slice(lineStart, lineEnd)); + if (!parsed) return; + yield parsed; + } +} + +function parentMarker( + value: string, + lineStart: number, + marker: ListMarker, + tokens: ReturnType, +): ListMarker | null { + const width = indentWidth(marker.indent); + for (const above of listMarkersAbove(value, lineStart, tokens)) { + if (indentWidth(above.marker.indent) < width) return above.marker; + } + return null; +} + +function nestedNumber( + marker: ListMarker, + previousChild: ListMarker | undefined, +): { replacedLength: number; text: string } { + if (marker.kind !== "ordered") return { replacedLength: 0, text: "" }; + const continues = + previousChild?.kind === "ordered" && previousChild.delimiter === marker.delimiter; + return { + replacedLength: marker.numberText.length, + text: continues + ? nextOrderedMarkerText(`${previousChild.numberText}${marker.delimiter}`).slice(0, -1) + : "1", + }; +} + export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null { if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null; const line = currentLine(value, cursor); @@ -159,12 +229,21 @@ export function listContinuationForEnter(value: string, cursor: number): Compose const tokens = inlineTokenRanges(value); if (isInsideInlineToken(tokens, cursor)) return null; if (value.slice(markerEnd, line.end).trim() === "") { - return { - start: line.start, - end: Math.max(cursor, markerEnd), - replacement: "", - cursorAfter: line.start, - }; + const parent = parentMarker(value, line.start, parsed.marker, tokens); + const replacement = parent ? nextMarkerText(parent) : ""; + const cursorAfter = line.start + replacement.length; + const end = Math.max(cursor, markerEnd); + const renumbered = + parent?.kind === "ordered" ? renumberFollowingItems(value, line.end, parent, tokens) : null; + if (renumbered) { + return { + start: line.start, + end: renumbered.end, + replacement: replacement + value.slice(end, line.end) + renumbered.text, + cursorAfter, + }; + } + return { start: line.start, end, replacement, cursorAfter }; } const insertion = `\n${nextMarkerText(parsed.marker)}`; const cursorAfter = cursor + insertion.length; @@ -190,7 +269,36 @@ export function listIndentForTab( ): ComposerListEdit | null { if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null; const line = currentLine(value, start); - if (!parseListMarker(line.text)) return null; - if (isInsideInlineToken(inlineTokenRanges(value), start)) return null; + const parsed = parseListMarker(line.text); + if (!parsed) return null; + const tokens = inlineTokenRanges(value); + if (isInsideInlineToken(tokens, start)) return null; + const { marker } = parsed; + const width = indentWidth(marker.indent); + let previousChild: ListMarker | undefined; + for (const above of listMarkersAbove(value, line.start, tokens)) { + const aboveWidth = indentWidth(above.marker.indent); + if (aboveWidth < width) break; + if (aboveWidth > width) { + if (!previousChild || aboveWidth < indentWidth(previousChild.indent)) { + previousChild = above.marker; + } + continue; + } + const indent = + previousChild?.indent ?? above.marker.indent + " ".repeat(above.contentColumn - aboveWidth); + const number = nestedNumber(marker, previousChild); + const replacedEnd = line.start + marker.indent.length + number.replacedLength; + const replacement = indent + number.text; + return { + start: line.start, + end: replacedEnd, + replacement, + cursorAfter: + start < replacedEnd + ? line.start + indent.length + : start - replacedEnd + line.start + replacement.length, + }; + } return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 }; } diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index f1042d2784aea..e19efe5345c58 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -118,6 +118,16 @@ const ICON_PATHS: Record(() => ({ notice: null })); -export function threadUndoNoticeSummary(notice: Pick): string { +function threadUndoNoticeSummary(notice: Pick): string { const noun = notice.action === "Discarded" ? "draft" : "thread"; return `${notice.action} ${notice.count} ${noun}${notice.count === 1 ? "" : "s"}`; } diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 3b8734c40765f..80400a2e4f541 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -115,6 +115,7 @@ vi.mock("./useSettings", () => ({ }), })); vi.mock("./useThreadActions", () => ({ + canTurnThreadIntoProject: () => false, useThreadActions: () => Object.fromEntries( [ diff --git a/apps/web/src/hooks/useThreadActionMenu.test.tsx b/apps/web/src/hooks/useThreadActionMenu.test.tsx index ca00a62fabab5..8dbf56d31b626 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.tsx +++ b/apps/web/src/hooks/useThreadActionMenu.test.tsx @@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({ setThreadAutoSettle: vi.fn(async () => ({ _tag: "Success" as const, value: undefined })), })); vi.mock("./useThreadActions", () => ({ + canTurnThreadIntoProject: () => false, useThreadActions: () => ({ setThreadAutoSettle: state.setThreadAutoSettle }), })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d15d517f2a0a9..5336b07b4f67f 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -44,7 +44,7 @@ import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; -import { useThreadActions } from "./useThreadActions"; +import { canTurnThreadIntoProject, useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { toastManager.add( @@ -82,6 +82,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, markThreadUnread, + turnThreadIntoProject, } = useThreadActions(); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -141,6 +142,7 @@ export function useThreadActionMenu(input: { isRunning: !threadRuntimeCanArchive(thread.runtime), supports, snoozePresets, + canTurnIntoProject: canTurnThreadIntoProject(threadRef), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -248,6 +250,19 @@ export function useThreadActionMenu(input: { case "mark-unread": markThreadUnread(threadRef); return; + case "turn-into-project": { + const result = await turnThreadIntoProject(threadRef); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Project created", + description: thread.title, + }); + } else if (!isAtomCommandInterrupted(result)) { + failureToast("Could not turn thread into a project", squashAtomCommandFailure(result)); + } + return; + } case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index b5d8a5c3a45b9..b0525af6395b3 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -28,6 +28,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { orchestrationEnvironment } from "../state/orchestration"; +import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -226,6 +227,20 @@ export function useAcknowledgeThreadWoke() { }; } +/** A No project thread that already has its own folder, on a server that can promote it. */ +export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean { + const thread = readThreadShell(threadRef); + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId); + if (!thread?.worktreePath || config?.environment.capabilities.threadProjectFromScratch !== true) { + return false; + } + const project = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + return project !== null && isScratchProject(project, config.scratchWorkspaceRoot); +} + function threadOperationFailure(target: ScopedThreadRef) { return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) ? null @@ -277,6 +292,9 @@ export function useThreadActions() { reportFailure: false, }); const markThreadUnread = useMarkThreadUnread(); + const createProjectFromThread = useAtomCommand(projectEnvironment.createFromThread, { + reportFailure: false, + }); const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -916,5 +934,10 @@ export function useThreadActions() { reorderActiveThread, markThreadUnread, setThreadAutoSettle, + turnThreadIntoProject: (target: ScopedThreadRef) => + createProjectFromThread({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3dd6af2985700..8e1607ea75f4a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2155,14 +2155,13 @@ code { } } -/* Diagnostics-style tables: row separators only, uppercase headers, and a - scroll-fade container for horizontal overflow. The root chat-markdown - wrapping rules (overflow-wrap: anywhere) would let columns shrink to single - characters and defeat the overflow — restore word-boundary wrapping so the - min column width is the longest word. */ +/* Diagnostics-style tables: row separators only, and a scroll-fade container + for horizontal overflow. Cells wrap and the table fits its lane. The root + chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns + shrink to single characters, so restore word-boundary wrapping: the min + column width is the longest word, and only wider content scrolls. */ .chat-markdown table { width: 100%; - min-width: max-content; border-collapse: collapse; overflow-wrap: normal; word-break: normal; @@ -2192,48 +2191,21 @@ code { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); padding-block: 0.55rem; font-weight: 600; - white-space: nowrap; } .chat-markdown tbody td { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } -/* Collapsed (default): single-line cells truncate so arbitrary chat content - cannot blow the column out; the footer toggle expands cells to wrap. */ -.chat-markdown .chat-markdown-table-container[data-expanded="false"] th, -.chat-markdown .chat-markdown-table-container[data-expanded="false"] td { - overflow: hidden; +/* Caps a long path, URL, or chip at 24rem; text past the cap wraps. */ +.chat-markdown :is(th, td) { max-width: 24rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-markdown .chat-markdown-table-container[data-expanded="true"] td { - max-width: 24rem; - overflow-wrap: anywhere; -} - -/* Fit expanded tables to wide timeline lanes; narrow lanes keep horizontal - scrolling so prose columns remain readable. */ -@container chat-lane (min-width: 36rem) { - .chat-markdown .chat-markdown-table-container[data-expanded="true"] table { - min-width: 0; - } - - .chat-markdown .chat-markdown-table-container[data-expanded="true"] th { - white-space: normal; - } - - .chat-markdown .chat-markdown-table-container[data-expanded="true"] td { - overflow-wrap: break-word; - } + overflow-wrap: break-word; } /* Formula cells keep their natural width and scroll with the table. */ -.chat-markdown .chat-markdown-table-container td:has([data-markdown-math]) { +.chat-markdown :is(th, td):has([data-markdown-math]) { max-width: none; - overflow: visible; } :root { diff --git a/apps/web/src/modelPickerVisibility.ts b/apps/web/src/modelPickerVisibility.ts index a8177fe83b5c6..17de95380fb87 100644 --- a/apps/web/src/modelPickerVisibility.ts +++ b/apps/web/src/modelPickerVisibility.ts @@ -1,8 +1,13 @@ -const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; +// Keydown handlers read this on every key, so a mount count replaces a document query. +let mountedModelPickers = 0; + +export function registerModelPickerContent(): () => void { + mountedModelPickers += 1; + return () => { + mountedModelPickers -= 1; + }; +} export function isModelPickerOpen(): boolean { - return ( - typeof document !== "undefined" && - document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null - ); + return mountedModelPickers > 0; } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 69800be4b7574..1178c91043052 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -5,14 +5,10 @@ import { threadContextReference } from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { - buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - resolvePendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, } from "./pendingUserInput"; describe("projectPendingUserInputAnswer", () => { @@ -68,128 +64,6 @@ const singleSelectQuestion = { multiSelect: false, } as const; -const nativeChoiceQuestion = { - id: "result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, -} as const; - -describe("resolvePendingUserInputAnswer", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( - question.initialAnswer, - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( - " Edited message\n\n", - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); - expect( - setPendingUserInputCustomAnswer( - { selectedOptionValues: ["Orchestration-first"] }, - "", - question, - ), - ).toEqual({ customAnswer: "" }); - const drafts = { scope: { customAnswer: "" } }; - expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ - customAnswer: "", - resolvedAnswer: "", - canAdvance: true, - isComplete: true, - }); - }); - - it("prefers a custom answer over selected options", () => { - expect( - resolvePendingUserInputAnswer(singleSelectQuestion, { - selectedOptionValues: ["Orchestration-first"], - customAnswer: "Keep the existing envelope for one release", - }), - ).toBe("Keep the existing envelope for one release"); - }); - - it("does not replace a required choice with a custom answer", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["second"], - customAnswer: "Use another result", - }), - ).toBe("second"); - }); - - it("does not submit labels or unknown values when an option has a value", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["Result", "unknown"], - }), - ).toBeNull(); - }); -}); - -describe("togglePendingUserInputOptionSelection", () => { - it("selects and removes options with the same label independently", () => { - const question = { ...nativeChoiceQuestion, multiSelect: true }; - const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); - const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); - - expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ - result: [" first\t", "second"], - }); - - const secondSelected = togglePendingUserInputOptionSelection( - question, - bothSelected, - " first\t", - ); - expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ - result: ["second"], - }); - }); -}); - -describe("buildPendingUserInputAnswers", () => { - it("returns a canonical answer map for complete prompts", () => { - expect( - buildPendingUserInputAnswers( - [ - singleSelectQuestion, - { - id: "compat", - header: "Compat", - question: "How strict should compatibility be?", - options: [ - { - label: "Keep current envelope", - description: "Preserve current wire format", - }, - ], - multiSelect: false, - }, - ], - { - scope: { - selectedOptionValues: ["Orchestration-first"], - }, - compat: { - customAnswer: "Keep the current envelope for one release window", - }, - }, - ), - ).toEqual({ - scope: "Orchestration-first", - compat: "Keep the current envelope for one release window", - }); - }); -}); - describe("pending user input question progress", () => { const questions = [ singleSelectQuestion, @@ -207,6 +81,17 @@ describe("pending user input question progress", () => { }, ] as const; + it("counts a deliberately cleared answer as answered", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + const drafts = { scope: { customAnswer: "" } }; + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("derives the active question and advancement state", () => { expect( derivePendingUserInputProgress( @@ -232,25 +117,6 @@ describe("pending user input question progress", () => { }); }); -it("accepts attachment-only answers after every upload finishes", () => { - const questions = [ - { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false }, - ]; - expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({ - spec: "", - }); - expect( - buildPendingUserInputAnswers(questions, { - spec: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], { - spec: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - describe("carryDisplacedCustomAnswerIntoPrompt", () => { // regression: #12577 clicking a question option discarded typed text it("keeps the thread draft when nothing was typed into the answer", () => { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index f8f9d6d52b5ba..17998eaf8010c 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,13 @@ import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { + buildPendingUserInputAnswers, + normalizeSelectedOptionValues, + resolvePendingUserInputAnswer, + type PendingUserInputAnswer, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; import { collectInlineContextIds } from "./lib/composerContextReferences"; @@ -25,20 +32,13 @@ export function threadContextsReferencedByAnswer( return threadContexts.filter((record) => referencedIds.has(record.contextId)); } -export interface PendingUserInputDraftAnswer { - selectedOptionValues?: string[]; - customAnswer?: string; - attachmentCount?: number; - attachmentsBlocked?: boolean; -} - export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; - selectedOptionValues: string[]; + selectedOptionValues: ReadonlyArray; customAnswer: string; - resolvedAnswer: string | string[] | null; + resolvedAnswer: PendingUserInputAnswer | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; @@ -46,72 +46,6 @@ export interface PendingUserInputProgress { canAdvance: boolean; } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeSelectedOptionValues(value: string[] | undefined): string[] { - if (!Array.isArray(value)) { - return []; - } - - // Provider option IDs must stay unchanged, including whitespace. - return Array.from(new Set(value.filter((entry) => typeof entry === "string"))); -} - -export function resolvePendingUserInputAnswer( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | string[] | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( - (value) => question.options.some((option) => (option.value ?? option.label) === value), - ); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - -export function setPendingUserInputCustomAnswer( - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, - question?: Pick, -): PendingUserInputDraftAnswer { - const selectedOptionValues = - question?.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(draft?.selectedOptionValues); - - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( @@ -128,48 +62,6 @@ export function carryDisplacedCustomAnswerIntoPrompt( return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; } -export function togglePendingUserInputOptionSelection( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); - const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) - ? selectedOptionValues.filter((value) => value !== optionValue) - : [...selectedOptionValues, optionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [optionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record | null { - const answers: Record = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray, draftAnswers: Record, @@ -203,7 +95,9 @@ export function derivePendingUserInputProgress( questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, - selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), + selectedOptionValues: activeQuestion + ? normalizeSelectedOptionValues(activeQuestion, activeDraft?.selectedOptionValues) + : [], customAnswer, resolvedAnswer, usingCustomAnswer: diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f905376dc341a..9c4e38c30e510 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1472,6 +1472,51 @@ describe("incremental v2 timeline entries", () => { expect(next.entries[index]).toBe(entry); }); + it("re-derives only the work entry whose tool item changed", () => { + const fixture = makeStreamingTimelineFixture("Partial"); + const visibleTurnItems = fixture.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, status: "running" as const, completedAt: null } } + : row, + ); + const input = { visibleTurnItems, optimisticMessages: [] }; + const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); + const nextInput = { ...input, visibleTurnItems: fixture.visibleTurnItems }; + const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); + expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); + for (const [index, entry] of previous.entries.entries()) { + if (entry.id === "live-work") expect(next.entries[index]).not.toBe(entry); + else expect(next.entries[index]).toBe(entry); + } + + const anchoredInput = { + ...nextInput, + anchoredMessages: [ + { + id: MessageId.make("feedback"), + runId: null, + role: "assistant" as const, + text: "Feedback received", + streaming: false, + createdAt: fixture.time(6), + updatedAt: fixture.time(6), + }, + ], + }; + const anchored = deriveTimelineEntriesFromVisibleTurnItemsWithState(anchoredInput); + const movedInput = { + ...anchoredInput, + visibleTurnItems: anchoredInput.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, startedAt: DateTime.makeUnsafe(fixture.time(8)) } } + : row, + ), + }; + expect( + deriveTimelineEntriesFromVisibleTurnItemsWithState(movedInput, anchored).entries, + ).toEqual(deriveTimelineEntriesFromVisibleTurnItems(movedInput)); + }); + it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)( "rebuilds entries for a %s change instead of retaining stale v2 metadata", (change) => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a0656234fc6ed..81e96f2609ae7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -903,6 +903,27 @@ export function isStreamingTurnItemTextUpdate( return Equal.equals(previousMetadata, nextMetadata); } +/** + * A changed tool or work item maps to exactly one entry with the item's id. + * Messages and question answers take part in cross-entry dedupe, and anchored + * messages are placed by createdAt, so those changes rebuild. + */ +function canRederiveInPlace( + input: TimelineEntriesInput, + previousItem: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2ProjectedTurnItem, +): boolean { + return ( + item.item.id === previousItem.item.id && + item.item.type === previousItem.item.type && + item.item.type !== "user_message" && + item.item.type !== "assistant_message" && + item.item.type !== "user_input_request" && + ((input.anchoredMessages?.length ?? 0) === 0 || + projectedItemCreatedAt(item) === projectedItemCreatedAt(previousItem)) + ); +} + function reuseTimelineEntries( input: TimelineEntriesInput, previous: TimelineEntriesProjection, @@ -943,15 +964,45 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); + const changedItems: OrchestrationV2ProjectedTurnItem[] = []; for (let index = 0; index < before.visibleTurnItems.length; index += 1) { const previousItem = before.visibleTurnItems[index]!; const item = input.visibleTurnItems[index]!; if (item === previousItem) continue; - if (!isStreamingTurnItemTextUpdate(previousItem, item)) return null; - replacements.set(previousItem, item); + if (isStreamingTurnItemTextUpdate(previousItem, item)) { + replacements.set(previousItem, item); + continue; + } + if (!canRederiveInPlace(input, previousItem, item)) return null; + changedItems.push(item); } - if (replacements.size === 0 && !appended) return previous.entries; + // One batch keeps the attempt/node/plan lookups to a single build. + const rederived = + changedItems.length === 0 + ? [] + : deriveTimelineEntriesFromVisibleTurnItems({ + ...input, + visibleTurnItems: changedItems, + optimisticMessages: [], + anchoredMessages: [], + }); + if ( + rederived.length !== changedItems.length || + rederived.some((entry, index) => entry.id !== changedItems[index]!.item.id) + ) { + return null; + } + const rederivedEntries = new Map(rederived.map((entry) => [entry.id, entry])); + if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) { + return previous.entries; + } + let rederivedCount = 0; const entries = previous.entries.map((entry): TimelineEntry => { + const rederivedEntry = rederivedEntries.get(entry.id); + if (rederivedEntry !== undefined) { + rederivedCount += 1; + return rederivedEntry; + } const row = entry.kind === "message" && entry.projectedItem !== undefined ? replacements.get(entry.projectedItem) @@ -967,6 +1018,7 @@ function reuseTimelineEntries( }, }; }); + if (rederivedCount !== rederivedEntries.size) return null; if (appended) { entries.push( ...deriveTimelineEntriesFromVisibleTurnItems({ diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index 72235447af444..a6a970f163c50 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -693,7 +693,10 @@ listed here is drift to remove rather than fork behavior to keep. attachment budget by `sizeBytes`, and forks carry `claudeAttachmentBudget` beside `claudeFork`. Upstream's out-of-process `runClaudeWorker` fork is not carried (forks use the CLI transport); only its `encodeClaudeJson` helper stays. - - Preview, device, and attachment-upload toolkits are not carried. + - Preview, device, and attachment-upload toolkits are not carried. provider-core's device + instructions (#25661) are upstream's but never reach an agent: no session holds the + `device` capability, and `ClaudeAdapterV2.ts` takes no `deviceToolsAvailable` input and + adds no `SubagentStart` device hook. - Provider input reads images only through `PastedImageAttachments.ts`: native `localImage` paths for Codex and base64 blocks for Claude. Read-tool image views are limited to PNG, JPEG, and WebP. Tool-result image bytes follow upstream: `stripUnservedToolOutputImageBytes` keeps @@ -762,7 +765,8 @@ listed here is drift to remove rather than fork behavior to keep. Code driver and probe tests. - `AcpRegistryRuntimeCoordinator.ts` has no URL authentication consent or session mutation permit. `adapter.ts` has no Devin, GitHub Copilot, or Mistral Vibe flavor (`copilotAcp.ts` - and `devinAcp.ts` are not carried) and no native protocol logging. + and `devinAcp.ts` are not carried), so neither Copilot's steering override (#26715) nor its + test, and no native protocol logging. - provider-acp's `adapter.ts` has no self-invocation: every ACP session gets no MCP server, and when the thread has a bridge command the first prompt carries `t3ToolBridgeInstructions` (moved to provider-core for this) through provider-core's @@ -790,7 +794,10 @@ listed here is drift to remove rather than fork behavior to keep. providers the workspace can reach. It still passes `--no-tools`. Claude's runner (`textGeneration/ClaudeTextGeneration.ts`) drops plain-text stdout lines before the first JSON line, because a managed workspace launcher prints a banner there first, as - `ClaudeCli.ts` allows for stream-json. + `ClaudeCli.ts` allows for stream-json. Upstream's out-of-usage fallback (#26164) is not + carried in text generation, `t3_thread_launch`'s project default, or the web: it reads + plan-window usage (`shared/usageLimits.ts`, not carried), which API-key providers never + report. `fallbackModelSelection` stays in the settings schema only. - Agent-session import follows upstream, scanning only the workspace's own Codex and Claude session stores through the helper. `coderServer.ts` provides the scanner beside the helper RPC layer, as upstream does beside its WebSocket layer. Upstream offers the import in its welcome @@ -890,6 +897,10 @@ listed here is drift to remove rather than fork behavior to keep. `WsRpcGroup` stays dormant, unreferenced, with the RPCs only it uses, and `knip.jsonc` ignores `rpc.ts`'s unused exports for it. Upstream's `EnvironmentAuthorizationError` stays in error unions but is never emitted. + - `CoderWsRpcGroup` carries `projects.createFromThread` (#26767), and the descriptor advertises + `threadProjectFromScratch`. `server.ts` mirrors `ipc.ts`'s `DesktopUpdateChannelSchema` for + `ServerSelfUpdateInput` (#26288), because `ipc.ts` is not carried; the descriptor omits + `desktopAppChannelSwitch` and `databaseMigration`. - `ServerConfig` omits auth, editors, remote open targets, and observability, except upstream's optional `lastEventLoopStall`, which stays in the schema but is never sent. - `vcs.ts` and `git.ts` keep upstream's shapes (GitLab's `GitLabCli` supplies its own @@ -961,7 +972,9 @@ listed here is drift to remove rather than fork behavior to keep. - **Project actions.** The thread details panel, action editor, and Settings → Actions are upstream's. Coder deltas: repository actions come from the helper's validated `t3.json` read by project (`projects.getConfig`), which drops `iconPath` and script preview fields; the editor has - no preview URL. + no preview URL. The thread details card's item choices (#26904) have no Open in, servers, or + usage items, so `threadDetailsUsageEnabled` is never read, and Labs' 5h, weekly, and context + limits view (#26859, `ThreadDetailsUsage`) is not carried. - **Right panel and composer chrome.** `RightPanelTabs.tsx` is upstream's tab strip and `PreviewPanelShell` layout without browser, device, and preview surfaces: no launcher rows, favicons, browser profiles, audio controls, or device rename. Never-opened preview and device @@ -1073,7 +1086,9 @@ listed here is drift to remove rather than fork behavior to keep. and a file outside a subfolder project opens by its absolute path. `projectFilesQueryState.ts` has no `isFile`, which only the uncarried `OpenInPicker` reads. `env.ts` reports `isElectron = false` for upstream's desktop branches. -- **Merge requests.** Upstream's page, panel, stack menu, and right-panel tabs, GitLab-only. Diffs +- **Merge requests.** Upstream's page, panel, stack menu, and right-panel tabs, GitLab-only. GitHub + maintainer edits (#25588) have no web control; the service and source-control-core plumbing is + upstream's, gated on a capability GitLab never advertises. Diffs come over the `pullRequests.diff` RPC without upstream's cross-environment GitHub routing, a project's default merge method is a workspace setting whose `null` means "last selected", and `!` references and GitLab wording are @@ -1223,7 +1238,7 @@ listed here is drift to remove rather than fork behavior to keep. - **Settings.** Upstream's layout, pages (#25651), navigation, and search, with Coder's Connections ("Environments"), Providers, GitLab, and background-activity panels. The access, browser, devices, SnapShot, and diagnostics pages are not carried, and `movedSettingsPath` never points at - them; Labs' Beta section has only the Linked environments switch (no guest links); About has no mobile app row or privacy policy link; + them; Labs' Beta section has only the Linked environments switch (no guest links or Simlock row); About has no mobile app row, privacy policy link, or update-track switch (#26288); storage cleanup lives on Maintenance and Worktrees as upstream's. `hooks/useEnvironmentDisconnectDelay.ts` keeps only upstream's `useEnvironmentStateHeldFor`. No Integrations, SnapShot, desktop, diagnostics, pairing, mobile app links (`MobileAppSettingsRow`), or `keybindings.json` editor. Settings @@ -1263,7 +1278,8 @@ listed here is drift to remove rather than fork behavior to keep. registry, whose final schema matches upstream ID 54, and rewrites the history to upstream's IDs before upstream's migrator runs. A V2 preview ledger is reconciled before that rewrite, because the Coder legacy registry never recorded `OrchestrationV2`. Any other history is left to - upstream's migrator and its V2 id-clash check, so `Migrations.test.ts` is upstream's. New + upstream's migrator and its V2 id-clash check, so `Migrations.test.ts` is upstream's, without + #26288's file-name check (it serves the npm launcher, and the auth IDs have no files). New migrations take upstream's next ID; never add a Coder-only migration ID. `migrationEntries` stays exported (upstream made it private) for the Coder legacy-history tests. - **Persistence.** Upstream's offline query persistence, which only its mobile app @@ -1347,9 +1363,9 @@ listed here is drift to remove rather than fork behavior to keep. `GitVcsDriverCore.fixture.sh` are not carried; `ProviderInstanceRegistry.test.ts` and `GitVcsDriverCore.test.ts` write the same stand-ins into their temporary directories (marked `Coder:`). -- **Build tooling.** `scripts/lib/third-party-licenses.ts` never fetches during a build: SPDX - texts are committed under `licenses/spdx`, and only `pnpm licenses:sync` downloads missing - ones. It reads no device tool version pins, because device tools are not carried. Upstream's +- **Build tooling.** `scripts/lib/third-party-licenses.ts` reads no device tool version pins, + because device tools are not carried (upstream's #26440 already commits the SPDX texts under + `third-party-licenses/spdx`, so builds never fetch). Upstream's CI-only checks are not carried, since the fork has no CI: the built web boot check (`apps/web/scripts/verify-built-boot.ts`) and the agent context budget test (`apps/server/src/provider/AgentContextBudget.test.ts` and `agentContextBudget.json`), which diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index befaca4c077c8..6bd4dea35d7c5 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -398,7 +398,10 @@ delegated to a linked environment name that environment. Clients open only that environment's thread and show its current title; a link to a thread they do not know renders as a label that opens nothing. Older `t3-thread://v1/` links name no environment: clients resolve them in the message's environment, -then by a unique match in another enabled environment. +then by a unique match in another enabled environment. An outside OAuth client, +one that is not a linked environment, cannot render `t3-thread://` links, so it +gets a web URL on the origin it reached, `//`, +for threads in this environment. List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e4f9ed7b0de28..d468eabc09576 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -30,6 +30,12 @@ folder, so the files the agent wrote stay until you delete them. Branch, worktre these folders are not Git repositories. This is unavailable when the data directory itself sits inside a Git checkout. +When the work grows into something real, pick **Turn into project** from the +thread's menu. The thread's folder becomes a project where it is, so nothing +moves and the agent keeps working in the same place. Other threads in that folder +move with it. Rename the project in its settings, and use **Initialize Git** to +get branch and diff controls. + ### Start in the background Press `Cmd+Enter` on macOS or `Ctrl+Enter` @@ -235,6 +241,11 @@ Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it finishes; the call's own result shows its status. +To choose what the thread details card shows, right-click the card or open +**Settings → Appearance → Thread details**. Hidden rows keep their keyboard +shortcuts, such as project scripts. **Reset to default** brings back the +standard rows. + ## Snooze until later To change the choices in the snooze menu, open **Settings → Threads → Snooze options**. diff --git a/knip.jsonc b/knip.jsonc index 719c5d9aa22d5..f77d8d082c110 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -78,6 +78,7 @@ "packages/client-runtime/src/state/shell.ts": ["exports", "nsExports"], "packages/client-runtime/src/state/shellPullRequests.ts": ["exports", "nsExports"], "packages/client-runtime/src/state/threadSettled.ts": ["exports", "nsExports"], + "packages/client-runtime/src/state/threadState.ts": ["exports", "nsExports"], "packages/client-runtime/src/textPaste.ts": ["exports", "nsExports"], "packages/contracts/src/acpRegistry.ts": ["exports", "nsExports"], "packages/contracts/src/auth.ts": ["exports", "nsExports"], @@ -120,6 +121,7 @@ "packages/shared/src/mcpApp.ts": ["exports", "nsExports"], "packages/shared/src/mcpResponsePayloads.ts": ["exports", "nsExports"], "packages/shared/src/orchestrationV2PendingBackgroundWork.ts": ["exports", "nsExports"], + "packages/shared/src/serverSettings.ts": ["exports", "nsExports"], "packages/shared/src/shell.ts": ["exports", "nsExports"], "packages/shared/src/sourceControl.ts": ["exports", "nsExports"], "packages/shared/src/t3McpToolPresentation.ts": ["exports", "nsExports"], diff --git a/licenses/spdx/README.md b/licenses/spdx/README.md deleted file mode 100644 index f016830889d66..0000000000000 --- a/licenses/spdx/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Bundled SPDX license texts - -These source files contain the `licenseId` and `licenseText` fields from SPDX License List -v3.28.0, pinned to revision `c4a7237ec8f4654e867546f9f409749300f1bf4c` of -[spdx/license-list-data](https://github.com/spdx/license-list-data/tree/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/details). -License text is preserved; package-specific copyright notices are applied during manifest generation. - -Normal builds read these checked-in files without downloading license data. When adding a license -to `third-party-licenses.config.json`, run `pnpm licenses:sync` with network access and commit the -new source file. Version and revision pins live in `scripts/lib/third-party-licenses.ts`. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 22c8c2cd5502c..ac26e4e67553f 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -266,6 +266,10 @@ "types": "./src/state/threadExecution.ts", "default": "./src/state/threadExecution.ts" }, + "./state/pending-user-input": { + "types": "./src/state/pendingUserInput.ts", + "default": "./src/state/pendingUserInput.ts" + }, "./state/thread-requests": { "types": "./src/state/threadRequests.ts", "default": "./src/state/threadRequests.ts" diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index f880dfe27e55b..b04f4318dabb0 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -223,7 +223,7 @@ describe("routine command transport budget", () => { if (command.type !== "message.dispatch") { throw new Error(`Expected message.dispatch, received ${command.type}`); } - if (!("sequence" in receipt)) { + if ("projection" in receipt) { throw new Error("Expected a dispatch receipt"); } const { deliveryIntent: _deliveryIntent, ...formerCommand } = command; diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts new file mode 100644 index 0000000000000..7f5a2b0b84c3d --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPendingUserInputAnswers, + isPendingUserInputOptionSelected, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, +} from "./pendingUserInput.ts"; + +const singleSelectQuestion = { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { label: "Orchestration-first", description: "Focus on orchestration first" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const nativeChoiceQuestion = { + id: "result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, +} as const; + +describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + question, + { selectedOptionValues: ["Orchestration-first"] }, + "", + ), + ).toEqual({ customAnswer: "" }); + }); + + it("prefers a custom answer over selected options", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + selectedOptionValues: ["Orchestration-first"], + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("does not replace a required choice with a custom answer", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["second"], + customAnswer: "Use another result", + }), + ).toBe("second"); + }); + + it("does not submit labels or unknown values when an option has a value", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["Result", "unknown"], + }), + ).toBeNull(); + }); + + it("matches a label only for an option without a value", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { selectedOptionValues: [" Node.js "] }), + ).toBe("Node.js"); + }); + + it("accepts ready attachment-only answers while preserving selected options", () => { + expect(resolvePendingUserInputAnswer(singleSelectQuestion, { attachmentCount: 1 })).toBe(""); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + selectedOptionValues: ["Node.js"], + }), + ).toBe("Node.js"); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + attachmentsBlocked: true, + }), + ).toBeNull(); + expect( + resolvePendingUserInputAnswer( + { ...singleSelectQuestion, allowCustomAnswer: false }, + { attachmentCount: 1 }, + ), + ).toBeNull(); + }); +}); + +describe("togglePendingUserInputOptionSelection", () => { + it("selects and removes options with the same label independently", () => { + const question = { ...nativeChoiceQuestion, multiSelect: true }; + const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); + const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); + + expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ + result: [" first\t", "second"], + }); + + const secondSelected = togglePendingUserInputOptionSelection( + question, + bothSelected, + " first\t", + ); + expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ + result: ["second"], + }); + }); + + it("submits raw option values and leaves the draft alone for an unknown option", () => { + const first = togglePendingUserInputOptionSelection( + nativeChoiceQuestion, + undefined, + " first\t", + ); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, " first\t")).toBe(true); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, "second")).toBe(false); + expect(buildPendingUserInputAnswers([nativeChoiceQuestion], { result: first })).toEqual({ + result: " first\t", + }); + expect(togglePendingUserInputOptionSelection(nativeChoiceQuestion, first, "Result")).toBe( + first, + ); + }); +}); + +describe("setPendingUserInputCustomAnswer", () => { + it("rejects arbitrary text when the provider only accepts offered options", () => { + expect(setPendingUserInputCustomAnswer(nativeChoiceQuestion, undefined, "Other")).toEqual({}); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { result: { customAnswer: "Other" } }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { + result: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, + }), + ).toEqual({ result: "second" }); + }); +}); + +const compatQuestion = { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [{ label: "Keep current envelope", description: "Preserve current wire format" }], + multiSelect: false, +} as const; + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, compatQuestion], { + scope: { selectedOptionValues: ["Orchestration-first"] }, + compat: { customAnswer: "Keep the current envelope for one release window" }, + }), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts new file mode 100644 index 0000000000000..1c71cdb29b4df --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -0,0 +1,176 @@ +/** The question fields answer handling reads. Web and mobile question types both fit it. */ +export interface PendingUserInputQuestion { + readonly id: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>; + readonly multiSelect?: boolean | undefined; + readonly allowCustomAnswer?: boolean | undefined; + readonly initialAnswer?: string | undefined; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionValues?: ReadonlyArray; + readonly customAnswer?: string; + readonly attachmentCount?: number; + readonly attachmentsBlocked?: boolean; +} + +export type PendingUserInputAnswer = string | ReadonlyArray; + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// Provider option values stay exact, including whitespace. Only options without a value match by label. +function resolvePendingUserInputOptionValue( + question: PendingUserInputQuestion, + value: string, +): string | null { + if (question.options.some((option) => option.value === value)) { + return value; + } + + const label = value.trim(); + return label.length > 0 && + question.options.some((option) => option.value === undefined && option.label.trim() === label) + ? label + : null; +} + +/** The draft's selections that name an offered option, deduplicated, in selection order. */ +export function normalizeSelectedOptionValues( + question: PendingUserInputQuestion, + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((entry) => resolvePendingUserInputOptionValue(question, entry)) + .filter((entry): entry is string => entry !== null), + ), + ); +} + +/** The answer to submit for one question, or null while it is unanswered. */ +export function resolvePendingUserInputAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, +): PendingUserInputAnswer | null { + if (draft?.attachmentsBlocked) return null; + const customAnswer = + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { + return customAnswer; + } + + const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + if (question.multiSelect) { + return selectedOptionValues.length > 0 + ? selectedOptionValues + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); + } + return ( + selectedOptionValues[0] ?? + customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) + ); +} + +export function setPendingUserInputCustomAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + if (question.allowCustomAnswer === false) { + return draft ?? {}; + } + + const selectedOptionValues = + question.initialAnswer !== undefined || customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + return { + customAnswer, + ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): boolean { + if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + return ( + resolvedOptionValue !== null && + normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( + resolvedOptionValue, + ) + ); +} + +/** Toggles an offered option. An unknown option returns the same draft. */ +export function togglePendingUserInputOptionSelection( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): PendingUserInputDraftAnswer { + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + if (resolvedOptionValue === null) { + return draft ?? {}; + } + + if (question.multiSelect) { + const selectedOptionValues = normalizeSelectedOptionValues( + question, + draft?.selectedOptionValues, + ); + const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) + : [...selectedOptionValues, resolvedOptionValue]; + + return { + customAnswer: "", + ...(nextSelectedOptionValues.length > 0 + ? { selectedOptionValues: nextSelectedOptionValues } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionValues: [resolvedOptionValue], + }; +} + +/** Every question's answer keyed by question id, or null while any is unanswered. */ +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); + if (answer === null) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 7218decfe8794..61e8a2ca09aef 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -204,6 +204,12 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + createFromThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-from-thread", + tag: WS_METHODS.projectsCreateFromThread, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 20e334288ab00..c40c9fe42d81e 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -59,6 +59,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { agents: [ member({ state: "completed", + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", startedAt: Date.UTC(2026, 7, 1, 10, 0, 0), durationMs: 1500, completedAt: Date.UTC(2026, 7, 1, 10, 0, 4, 432), @@ -74,6 +77,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { expect(settled?.status).toBe("completed"); expect(settled?.completedAt).toBe("2026-08-01T10:00:04.432Z"); expect(settled?.result).toBe("A1"); + expect(settled?.model).toBe("claude-opus-5[1m]"); + expect(settled?.effort).toBe("xhigh"); + expect(settled?.speed).toBe("standard"); expect(settled?.progress).toBe("Reply with exactly: A1"); expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); }); @@ -108,6 +114,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { "2026-08-01T10:00:09.000Z", "2026-08-01T10:00:01.500Z", ]); + expect(members?.[0]?.model).toBeNull(); + expect(members?.[0]?.effort).toBeNull(); + expect(members?.[0]?.speed).toBeUndefined(); }, ); }); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index c007bc56b9743..e3807362fbdf6 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -52,6 +52,7 @@ export interface RuntimeSubagent { readonly role: string | null; readonly model: string | null; readonly effort: string | null; + readonly speed?: string | undefined; readonly status: RuntimeSubagentStatus; readonly activationCount: number; readonly usage: SubagentUsage | null; @@ -150,6 +151,8 @@ function workflowMembersToRuntime( kind: "workflow_agent" as const, title: agent.label, model: agent.model ?? null, + effort: agent.effort ?? null, + ...(agent.speed === undefined ? {} : { speed: agent.speed }), status, activationCount: agent.attempt ?? 1, usage: workflowUsage(agent), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0c93800378797..e1f30998caefb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -409,6 +409,12 @@ export function createEnvironmentThreadDetailAtoms( Atom.withLabel(`environment-thread-status:${key}`), ), ); + const sequenceAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).sequence).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-sequence:${key}`), + ), + ); const errorAtomFamily = Atom.family((key: string) => Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( Atom.setIdleTTL(0), @@ -439,6 +445,7 @@ export function createEnvironmentThreadDetailAtoms( quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), + sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)), }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index accf5c98df9a9..ba87930bebbd5 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -151,6 +151,7 @@ export const REASONING_EFFORT_OPTION_IDS: ReadonlyArray = [ "reasoning", "variant", "reasoning_effort", + "thinking_level", ]; export function formatModelSelectionEffort( diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 67b227089ab15..3b53b68b2f4fa 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -14,6 +14,8 @@ export interface EnvironmentThreadState { * disconnect path. */ readonly history: ThreadHistoryMeta; + /** The last server sequence `data` reflects, so readers can tell a write has landed without searching paged-out history. */ + readonly sequence: number; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { @@ -21,4 +23,21 @@ export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { status: "empty", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: 0, }; + +/** + * A thread holds a write once it has applied the write's commit sequence. A bounded + * projection can page the message out later, so finding it is only a fallback for + * servers that do not report the sequence. + */ +export function threadHasCaughtUpTo( + write: { readonly messageId: string; readonly sequence?: number | undefined }, + thread: { + readonly sequence: number; + readonly messages: ReadonlyArray<{ readonly id: string }> | undefined; + }, +): boolean { + if (write.sequence !== undefined && thread.sequence >= write.sequence) return true; + return thread.messages?.some((entry) => entry.id === write.messageId) ?? false; +} diff --git a/packages/client-runtime/src/state/threadSubagents.test.ts b/packages/client-runtime/src/state/threadSubagents.test.ts index 26b9a785c02d3..28544450bfc4e 100644 --- a/packages/client-runtime/src/state/threadSubagents.test.ts +++ b/packages/client-runtime/src/state/threadSubagents.test.ts @@ -56,6 +56,33 @@ const activeRun = run("run-1", "running"); const finishedRun = run("run-1", "completed"); describe("deriveThreadTurnSubagents", () => { + it.each(["running", "completed"] as const)( + "keeps finished workflows after a later %s turn", + (status) => { + const workflow = subagent({ + id: "workflow", + status: "completed", + workflow: { phases: [], agents: [] }, + }); + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun, run("run-2", status)], + subagents: [ + workflow, + subagent({ id: "old", status: "completed" }), + subagent({ + id: "recent", + runId: RunId.make("run-2"), + status, + updatedAt: at("2026-06-20T00:00:09.000Z"), + }), + ], + }); + + expect(turn?.subagents.map((entry) => entry.id)).toEqual(["recent", "workflow"]); + expect(resolveSubagentPillSegment(turn)).not.toBeNull(); + }, + ); + it("scopes the roster to the active run and orders it by start time", () => { const turn = deriveThreadTurnSubagents({ runs: [run("run-0", "completed"), activeRun], @@ -264,15 +291,16 @@ describe("deriveThreadTurnSubagents", () => { const turn = deriveThreadTurnSubagents({ runs: [activeRun], subagents: [ - subagent({ id: "a", status: "running" }), + subagent({ id: "a", status: "completed" }), subagent({ id: "b", status: "idle" }), subagent({ id: "c", status: "failed" }), ], }); - expect(turn?.liveCount).toBe(1); - expect(turn?.settledCount).toBe(1); + expect(turn?.liveCount).toBe(0); + expect(turn?.settledCount).toBe(2); expect(turn?.subagents).toHaveLength(3); + expect(resolveSubagentPillSegment(turn)?.label).toBe("2 done"); }); it("keeps settled workflows reachable without counting idle agents as done", () => { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 74e5c73f4f8ec..d78828e3a4cd5 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -717,7 +717,7 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); - yield* awaitThreadState( + const live = yield* awaitThreadState( harness.observed, (value) => value.status === "live" && @@ -725,6 +725,8 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Live title", ); + // Readers compare this to a command's commit sequence; it moves with the data it describes. + expect(live.sequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); }), @@ -1292,6 +1294,7 @@ describe("EnvironmentThreads", () => { status: "cached", error: Option.none(), history: { ...EMPTY_THREAD_HISTORY_META, historyCursor: null, expanded: true }, + sequence: CACHED_SNAPSHOT_SEQUENCE, }, sequence: CACHED_SNAPSHOT_SEQUENCE, persisted: true, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index bbd5e09924b7d..c641ab900fc51 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -235,6 +235,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) : Option.none(); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); + // Seed the resume cursor from the cached snapshot so a warm cache can catch up + // via `afterSequence` instead of re-downloading the full thread body. + const initialSequence = + retained?.sequence ?? + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { @@ -245,16 +250,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onNone: () => EMPTY_THREAD_HISTORY_META, onSome: historyMetaFromCachedSnapshot, }), + sequence: initialSequence, }; const state = yield* SubscriptionRef.make(initialState); // Coder: history pages use helper stdio, so only the controller is required. const canLoadHistory = Option.isSome(historyController); const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); - // Seed the resume cursor from the cached snapshot so a warm cache can catch up - // via `afterSequence` instead of re-downloading the full thread body. - const initialSequence = - retained?.sequence ?? - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, @@ -412,6 +413,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ) { const waiting = yield* Ref.get(awaitingCompletion); + const sequence = yield* SubscriptionRef.get(lastSequence); // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. @@ -432,6 +434,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : ("live" as const), error: previous.error, history, + sequence, }; }); // Active projections can update many times per second and retain large tool @@ -463,6 +466,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "deleted", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: yield* SubscriptionRef.get(lastSequence), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; @@ -583,7 +587,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (applied._tag !== "noop") result = applied; if (applied._tag === "delete") break; } - return [result, current]; + // A batch that changed nothing keeps its state object, so readers do not re-render. + return [result, result._tag === "noop" ? current : { ...current, sequence }]; }, ); @@ -805,7 +810,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); if (result._tag !== "loaded") return result; - if (complete) yield* remember; + if (complete) yield* applyLock.withPermits(1)(remember); nextCursor = keepPaging ? page.nextCursor : null; } if (nextCursor === null) return { _tag: "loaded" } as const; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6c6106cc089e1..255085cbd6ebc 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.3", + "version": "0.1.4", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 3cd80feea6b73..f9564b2e2441c 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -33,6 +33,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 0fc7fde4c3bd4..24b72dbe26d7a 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({ ); export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type; -export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig; +/** + * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock). + * T3 never installs Simlock; the gateway and its workers are the user's own. + */ +export const SimlockDeviceHostConfig = Schema.Struct({ + kind: Schema.Literal("simlock"), + id: ConfiguredDeviceHostId, + label: TrimmedNonEmptyString, + /** The gateway's origin, such as https://gateway.example.ts.net:4700. */ + gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)), + /** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */ + agentToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), + maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(2)), + ), + /** A lease the Device panel keeps alive ends this long after it started. */ + maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(60)), + ), +}); +export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type; + +export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig; const uniqueHostIds = Schema.makeFilter( (hosts: ReadonlyArray<{ readonly id: string }>) => @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq */ export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds); +/** Kept apart from the other host lists for the same reason as EAS hosts. */ +export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds); + export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), @@ -99,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", @@ -161,7 +216,15 @@ const EasDeviceHostSummary = Schema.Struct({ ...deviceHostSummaryFields, kind: Schema.Literal("eas"), }); -export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]); +const SimlockDeviceHostSummary = Schema.Struct({ + ...deviceHostSummaryFields, + kind: Schema.Literal("simlock"), +}); +export const DeviceHostSummary = Schema.Union([ + MachineDeviceHostSummary, + EasDeviceHostSummary, + SimlockDeviceHostSummary, +]); export type DeviceHostSummary = typeof DeviceHostSummary.Type; export const DeviceHostStatus = Schema.Literals([ @@ -198,6 +261,8 @@ const deviceServiceStateFields = { supportsToolInspection: Schema.optional(Schema.Boolean), /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */ supportsEasHosts: Schema.optional(Schema.Boolean), + /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */ + supportsSimlockHosts: Schema.optional(Schema.Boolean), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: DeviceHostStatuses, @@ -221,6 +286,7 @@ const DeviceServiceStateWire = Schema.Struct({ ...deviceServiceStateFields, hosts: Schema.Array(MachineDeviceHostSummary), easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)), + simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)), }); const DeviceServiceStateDecoded = Schema.Struct({ ...deviceServiceStateFields, @@ -234,18 +300,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe( typeof DeviceServiceStateDecoded.Type, typeof DeviceServiceStateWire.Type >({ - decode: ({ easHosts, ...state }) => ({ + decode: ({ easHosts, simlockHosts, ...state }) => ({ ...state, - hosts: [...state.hosts, ...(easHosts ?? [])], + hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])], }), encode: (state) => ({ ...state, hosts: state.hosts.filter( - (host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas", + (host): host is typeof MachineDeviceHostSummary.Type => + host.kind === "local" || host.kind === "ssh", ), easHosts: state.hosts.filter( (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas", ), + simlockHosts: state.hosts.filter( + (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock", + ), }), }), ), @@ -492,13 +562,14 @@ export class DeviceBootError extends Schema.TaggedError()("Devi }) { override get message(): string { const explanation = { - disk_space: "There is not enough free disk space on the environment server.", + disk_space: "There is not enough free disk space on its device host.", timeout: "The device did not become ready in time.", - launch_failed: - "The simulator or emulator could not start. Check its configuration on the environment server.", + launch_failed: "The simulator or emulator could not start.", }[this.reason]; const summary = `Device ${this.deviceId} failed to boot: ${explanation}`; - return this.diagnostics ? `${summary}\n\n${this.diagnostics}` : summary; + return this.diagnostics + ? `${summary}\n\n${this.diagnostics}` + : `${summary} The device host sent no details. Try again or open another device; if it keeps failing, check the device on that host.`; } } @@ -584,6 +655,7 @@ export const DeviceToolOpenResult = Schema.Struct({ agentDevice: Schema.Struct({ command: Schema.String, targetArgs: Schema.Array(Schema.String), + session: Schema.String, }), quickStart: Schema.String, }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a65a91e0d3921..75a546bed875f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -110,6 +110,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), threadActiveReorder: Schema.optionalKey(Schema.Boolean), threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + threadProjectFromScratch: Schema.optionalKey(Schema.Boolean), threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), threadVisitedTracking: Schema.optionalKey(Schema.Boolean), threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), @@ -132,6 +133,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** The desktop app switches update tracks when a server update request names one. */ + desktopAppChannelSwitch: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ @@ -159,6 +162,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), + /** Highest migration this server's database holds; absent on older servers. */ + databaseMigration: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 2414d3cd01984..3782307171e87 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -358,6 +358,9 @@ describe("orchestration V2 contracts", () => { index: 1, label: "Member", state: "completed" as const, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", prompt: preview, result: preview, }, @@ -379,10 +382,16 @@ describe("orchestration V2 contracts", () => { expect(decode(json).workflow?.agents[0]).toMatchObject({ prompt: preview, result: preview, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", }); expect(decode(incoming).workflow?.agents[0]).toMatchObject({ prompt: preview, result: preview, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", }); } }, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c4d72265e6963..973128da87593 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -787,6 +787,8 @@ export const OrchestrationV2WorkflowAgent = Schema.Struct({ phaseIndex: Schema.optional(NonNegativeInt), phaseTitle: Schema.optional(TrimmedNonEmptyString), model: Schema.optional(TrimmedNonEmptyString), + effort: Schema.optional(TrimmedNonEmptyString), + speed: Schema.optional(TrimmedNonEmptyString), attempt: Schema.optional(NonNegativeInt), totalTokens: Schema.optional(NonNegativeInt), toolCalls: Schema.optional(NonNegativeInt), @@ -3343,6 +3345,13 @@ const OrchestrationV2InternalCommand = Schema.Union([ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), }), + /** Moves a thread to a project rooted at the folder it already works in, so its cwd never changes. */ + Schema.Struct({ + type: Schema.Literal("thread.project.adopt-folder"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + }), Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, @@ -3581,6 +3590,8 @@ export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ threadId: ThreadId, projection: OrchestrationV2ThreadProjection, resumed: Schema.Boolean, + /** The initial message's commit sequence. Older servers omit it. */ + sequence: Schema.optionalKey(NonNegativeInt), }); export type OrchestrationV2ThreadLaunchResult = typeof OrchestrationV2ThreadLaunchResult.Type; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 9b9100287f9c6..1474a5d6e87b6 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -330,6 +330,7 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional( export const OrchestratorMcpThreadListInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, + allProjects: Schema.optional(Schema.Boolean), statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ), @@ -353,6 +354,7 @@ export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullR export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, + projectId: ProjectId, title: Schema.String, link: Schema.optional(Schema.String), createdBy: OrchestrationV2Actor, @@ -378,7 +380,8 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem.Type; export const OrchestratorMcpThreadListResult = Schema.Struct({ - projectId: ProjectId, + /** null with `allProjects`. */ + projectId: Schema.NullOr(ProjectId), currentThreadId: Schema.NullOr(ThreadId), threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 07be4843bc5f5..1a5b1e9247a1d 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -10,6 +10,7 @@ import { NonNegativeInt, PositiveInt, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, type UnknownUnionMember, @@ -529,6 +530,16 @@ export const ProjectCreateNewResult = Schema.Struct({ }); export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; +export const ProjectCreateFromThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ProjectCreateFromThreadInput = typeof ProjectCreateFromThreadInput.Type; + +export const ProjectCreateFromThreadResult = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 8d33c6da7f0f0..cf99deb7c8c13 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -244,6 +244,7 @@ export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities export const PullRequestEditCapabilities = Schema.Struct({ changeRequest: Schema.Boolean, comment: Schema.Boolean, + maintainerEdits: Schema.optional(Schema.Boolean), }); export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type; @@ -295,6 +296,7 @@ export const PullRequestViewerPermissions = Schema.Struct({ updateMethods: Schema.optional(ForwardCompatibleArray(PullRequestUpdateMethod)), labels: Schema.optional(Schema.Boolean), editChangeRequest: Schema.optional(Schema.Boolean), + maintainerEdits: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; @@ -565,6 +567,7 @@ export const PullRequestDetail = Schema.Struct({ autoMergeEnabled: Schema.optional(Schema.Boolean), autoMergeMethod: Schema.optional(PullRequestMergeMethod), workflowApprovalsRequired: Schema.optional(NonNegativeInt), + maintainerCanModify: Schema.optional(Schema.Boolean), }); export type PullRequestDetail = typeof PullRequestDetail.Type; @@ -722,6 +725,7 @@ export const PullRequestUpdateInput = Schema.Struct({ ...PullRequestRef.fields, title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1024))), body: Schema.optional(Schema.String.check(Schema.isMaxLength(65_536))), + maintainerCanModify: Schema.optional(Schema.Boolean), }); export type PullRequestUpdateInput = typeof PullRequestUpdateInput.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7beb2ad882c75..7dae9d2598b74 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -185,6 +185,8 @@ import { import { ProjectCreateNewInput, ProjectCreateNewResult, + ProjectCreateFromThreadInput, + ProjectCreateFromThreadResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -248,6 +250,9 @@ import { DeviceEasAccount, DeviceEasAccountError, DeviceEasAccountInput, + DeviceSimlockIdentity, + DeviceSimlockIdentityError, + DeviceSimlockIdentityInput, DeviceHostSummary, DeviceOpenInput, DeviceServiceState, @@ -378,6 +383,7 @@ export const WS_METHODS = { projectsMutate: "projects.mutate", projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", + projectsCreateFromThread: "projects.createFromThread", shellOpenInEditor: "shell.openInEditor", @@ -449,6 +455,7 @@ export const WS_METHODS = { deviceList: "device.list", deviceTestHost: "device.testHost", deviceEasAccount: "device.easAccount", + deviceSimlockIdentity: "device.simlockIdentity", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1161,6 +1168,12 @@ const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +const WsProjectsCreateFromThreadRpc = Rpc.make(WS_METHODS.projectsCreateFromThread, { + payload: ProjectCreateFromThreadInput, + success: ProjectCreateFromThreadResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsFilesystemGetMetadataRpc = Rpc.make(WS_METHODS.filesystemGetMetadata, { payload: FilesystemGetMetadataInput, success: FilesystemGetMetadataResult, @@ -1448,6 +1461,12 @@ const WsDeviceEasAccountRpc = Rpc.make(WS_METHODS.deviceEasAccount, { error: Schema.Union([DeviceEasAccountError, EnvironmentAuthorizationError]), }); +const WsDeviceSimlockIdentityRpc = Rpc.make(WS_METHODS.deviceSimlockIdentity, { + payload: DeviceSimlockIdentityInput, + success: DeviceSimlockIdentity, + error: Schema.Union([DeviceSimlockIdentityError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -1838,6 +1857,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, WsProjectsCreateNewRpc, + WsProjectsCreateFromThreadRpc, WsProjectsWriteFileRpc, WsProjectsMutateRpc, WsFilesystemBrowseRpc, @@ -1890,6 +1910,7 @@ export const WsRpcGroup = RpcGroup.make( WsDeviceListRpc, WsDeviceTestHostRpc, WsDeviceEasAccountRpc, + WsDeviceSimlockIdentityRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, @@ -2022,6 +2043,7 @@ const WsOrchestrationGetThreadTranscriptRpc = Rpc.make( export const CoderWsRpcGroup = RpcGroup.make( WsProjectsEnsureScratchRpc, WsProjectsCreateNewRpc, + WsProjectsCreateFromThreadRpc, WsProjectsMutateRpc, WsSubscribeWorktreeSetupRpc, WsWorktreeSetupCancelRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0b3d75768caf2..8054ac38500b1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -27,6 +27,8 @@ import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; +// Coder: mirrors ipc.ts's DesktopUpdateChannelSchema, since ipc.ts (desktop IPC) is not carried. +const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -871,6 +873,10 @@ export class ServerProviderUpdateError extends Schema.TaggedError entry.slug), promptFailure: acpRegistryPromptFailure, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index fed56ed584d99..3262c16d9ec68 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -75,6 +75,7 @@ import { parsePermissionRequest, parseSessionUpdateEvent, toolCallVisibleOutputChanged, + withTrackedPermissionToolCall, type AcpPlanUpdate, type AcpAgentTerminalState, type AcpSessionModeState, @@ -287,7 +288,7 @@ export interface AcpAdapterV2Flavor { request: EffectAcpSchema.ReadTextFileRequest, cwd: string | null, ) => Effect.Effect; - readonly writeTextFile: ( + readonly writeTextFile?: ( request: EffectAcpSchema.WriteTextFileRequest, cwd: string | null, ) => Effect.Effect; @@ -1969,7 +1970,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( clientCapabilities: { fs: { readTextFile: flavor.clientFileSystem !== undefined, - writeTextFile: flavor.clientFileSystem !== undefined, + writeTextFile: flavor.clientFileSystem?.writeTextFile !== undefined, }, terminal: clientTerminals !== undefined, elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, @@ -5934,11 +5935,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), ), ); - yield* targetRuntime.handleWriteTextFile((request) => - clientPolicyContext.pipe( - Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), - ), - ); + const writeTextFile = clientFileSystem.writeTextFile; + if (writeTextFile !== undefined) { + yield* targetRuntime.handleWriteTextFile((request) => + clientPolicyContext.pipe( + Effect.flatMap(({ policy }) => writeTextFile(request, policy.cwd)), + ), + ); + } } if (handlerOptions.mcp !== false) { yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); @@ -5951,8 +5955,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), ); } - yield* targetRuntime.handleRequestPermission((params, requestContext) => + yield* targetRuntime.handleRequestPermission((nativeParams, requestContext) => Effect.gen(function* () { + const trackedTurn = yield* Ref.get(activeTurn); + const params = + trackedTurn !== null && nativeParams.sessionId === (yield* Ref.get(activeSessionId)) + ? withTrackedPermissionToolCall( + nativeParams, + trackedTurn.tools.get(nativeParams.toolCall.toolCallId), + ) + : nativeParams; const transportRequestId = requestContext.requestId; const permissionQuestion = flavor.extractPermissionQuestion?.(params); if (permissionQuestion !== undefined) { @@ -7149,10 +7161,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { const prompt: Array = []; // Coder: T3 tools exist when the thread has a bridge command, not an MCP server. - const toolCommand = (yield* readMcpContext(turnInput.threadId)).toolCommand; + const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const toolCommand = acpMcpContext(mcpSession).toolCommand; const instructionState = { interactionMode: turnInput.runtimePolicy.interactionMode, hasT3Mcp: toolCommand !== undefined, + hasDeviceTools: mcpSession?.capabilities?.has("device") === true, } satisfies T3AcpInstructionState; const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); const compactionMarker = (yield* Ref.get(compactionMarkers)).get(sessionId); diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 70682ec4824a5..e60671c775d51 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -17,6 +17,7 @@ import { sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, toolCallProgressLength, + withTrackedPermissionToolCall, type AcpToolCallState, } from "./runtimeModel.ts"; @@ -432,6 +433,40 @@ describe("AcpRuntimeModel", () => { }); }); + it("keeps an explicit permission request kind over the tracked tool call's kind", () => { + const request = withTrackedPermissionToolCall( + { + sessionId: "session-1", + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + toolCall: { toolCallId: "tool-1", kind: "read" }, + }, + { + toolCallId: "tool-1", + kind: "execute", + data: { toolCallId: "tool-1", title: "git status", rawInput: { command: "git status" } }, + }, + ); + + expect(parsePermissionRequest(request)).toMatchObject({ kind: "read", detail: "git status" }); + }); + + it("keeps explicitly cleared permission request fields over the tracked tool call", () => { + const request = withTrackedPermissionToolCall( + { + sessionId: "session-1", + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + toolCall: { toolCallId: "tool-1", kind: null, title: null, rawInput: null }, + }, + { + toolCallId: "tool-1", + kind: "execute", + data: { toolCallId: "tool-1", title: "git status", rawInput: { command: "git status" } }, + }, + ); + + expect(parsePermissionRequest(request).kind).toBe("unknown"); + }); + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( "\n", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 87f24c5afdbaa..43fa2bf758e2f 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1179,6 +1179,29 @@ export function embeddedTerminalIdsFromSessionUpdate( return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; } +/** ACP lets a permission request name its tool call by ID only; omitted (not null) fields come from the tracked call. */ +export function withTrackedPermissionToolCall( + params: EffectAcpSchema.RequestPermissionRequest, + tracked: AcpToolCallState | undefined, +): EffectAcpSchema.RequestPermissionRequest { + if (tracked === undefined) return params; + const { title, kind, rawInput } = params.toolCall; + return { + ...params, + toolCall: { + ...params.toolCall, + ...(title === undefined && typeof tracked.data.title === "string" + ? { title: tracked.data.title } + : {}), + // Unrecognised kinds map to command approvals, so only a tracked execute kind may grant execution. + ...(kind === undefined && tracked.kind === "execute" ? { kind: "execute" as const } : {}), + ...(rawInput === undefined && tracked.data.rawInput !== undefined + ? { rawInput: tracked.data.rawInput } + : {}), + }, + }; +} + export function parsePermissionRequest( params: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionRequest { diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 668502f93e432..7794107824c9b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,6 +6,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -138,6 +139,116 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const cache = yield* Cache.makeWith( + () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: count === 1 ? 20 : 100, + }, + ], + }, + } satisfies ServerProvider; + }), + { capacity: 1, timeToLive: () => "5 minutes" }, + ); + const warmed = yield* Cache.get(cache, "claude"); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(warmed), + checkProvider: Cache.get(cache, "claude"), + invalidateUsageCache: Cache.invalidateAll(cache), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.strictEqual(yield* Ref.get(calls), 1); + const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const queued = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(refresh); + yield* Fiber.join(queued); + assert.strictEqual(yield* Ref.get(calls), 3); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("keeps the waiting usage refresh's claim when the running one is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const usageProbeStarted = yield* Deferred.make(); + const statusProbeStarted = yield* Deferred.make(); + const releaseStatusProbe = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(usageProbeStarted, undefined); + return yield* Effect.never; + } + if (count === 3) { + yield* Deferred.succeed(statusProbeStarted, undefined); + yield* Deferred.await(releaseStatusProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + } satisfies ServerProvider; + }), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + const running = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(usageProbeStarted); + const status = yield* provider.refresh.pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(running); + yield* Deferred.await(statusProbeStarted); + const extra = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStatusProbe, undefined); + yield* Fiber.join(status); + yield* Fiber.join(waiting); + yield* Fiber.join(extra); + assert.strictEqual(yield* Ref.get(calls), 4); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index f40baee525c8d..41ff65b0f294b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -44,6 +44,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -60,6 +61,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); + const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -216,6 +218,38 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { + const { snapshot } = yield* Ref.get(snapshotStateRef); + if ( + !snapshot.enabled || + !snapshot.usageLimits || + snapshot.usageLimits.unavailable?.reason === "unsupported" + ) + return; + // An in-flight refresh may have read cached usage, so one refresh waits behind it. + const claim = Symbol(); + const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); + yield* Effect.uninterruptibleMask((restore) => + Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( + Effect.flatMap((held) => + held + ? Effect.void + : restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }), + ), + ), + ), + ).pipe(Effect.onInterrupt(() => release)), + ), + ), + ); + }); + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only @@ -304,6 +338,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( .resolveMaintenance({ fresh: true }) .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, + refreshUsage: refreshUsage(), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts new file mode 100644 index 0000000000000..c8dd21cc02634 --- /dev/null +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -0,0 +1,85 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, + t3AcpPromptWithInstructions, + t3OrchestrationPrompt, + t3OrchestrationSystemPrompt, +} from "./orchestrationInstructions.ts"; + +describe("T3 device instructions", () => { + it("route device work through device_open and give Claude the line that loads the tools", () => { + for (const intent of ["`device_list`", "`device_open`", "`agent-device`", "ssh", "`hostId`"]) { + assert.include(T3_CODE_DEVICE_TOOL_INSTRUCTIONS, intent); + } + assert.notInclude(T3_CODE_DEVICE_TOOL_INSTRUCTIONS, "ToolSearch"); + const loadLine = + " Load them with ToolSearch `select:mcp__t3-code__device_list,mcp__t3-code__device_open,mcp__t3-code__device_screenshot,mcp__t3-code__device_close`."; + assert.include(T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, loadLine); + assert.equal( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH.replace(loadLine, ""), + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + ); + }); + + it("open the app before the returned command's snapshots, screenshots and input", () => { + assert.include( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + "its snapshots, screenshots and input need `open ` first,", + ); + }); + + it("keep the failure rule from taking over or closing a session another thread holds", () => { + // agent-device's DEVICE_IN_USE advice is to rerun with the owner's --session or close it first. + assert.include( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + "follow its advice once, but never reuse or close another session; if it still fails,", + ); + }); + + it("reach OpenCode and Cursor only when the session holds device access", () => { + assert.include(t3OrchestrationSystemPrompt(true, true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(t3OrchestrationSystemPrompt(true, false), "## T3 Code devices"); + assert.isUndefined(t3OrchestrationSystemPrompt(false, true)); + + const firstRun = (hasDeviceTools: boolean) => + t3OrchestrationPrompt({ + prompt: "Check the login screen.", + hasT3Mcp: true, + hasDeviceTools, + orchestrationDelivered: false, + deviceInstructionsDelivered: false, + }); + assert.include(firstRun(true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(firstRun(false), "## T3 Code devices"); + + const laterRun = (deviceInstructionsDelivered: boolean) => + t3OrchestrationPrompt({ + prompt: "Check the login screen.", + hasT3Mcp: true, + hasDeviceTools: true, + orchestrationDelivered: true, + deviceInstructionsDelivered, + }); + assert.include(laterRun(false), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(laterRun(false), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()); + assert.equal(laterRun(true), "Check the login screen."); + }); + + it("reach ACP agents again when device access changes mid-thread", () => { + const state = { interactionMode: "default", hasT3Mcp: true, hasDeviceTools: false } as const; + const withDevices = t3AcpPromptWithInstructions({ + prompt: "Check the login screen.", + state: { ...state, hasDeviceTools: true }, + previousState: state, + }); + assert.include(withDevices, T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.equal( + t3AcpPromptWithInstructions({ prompt: "Again.", state, previousState: state }), + "Again.", + ); + assert.notInclude(t3AcpPromptWithInstructions({ prompt: "Hi.", state }), "## T3 Code devices"); + }); +}); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 0cbb6c39fbfb2..89acc3d33fa67 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -38,6 +38,25 @@ export const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` When browser work was asked, \`preview_*\` is the browser you share with the user: \`preview_status\` first, then \`preview_open\` if no tab can be automated. Prefer snapshot locators over coordinates. Switch browsers only if the tools are absent or \`preview_open\` says unsupported, the user asks, or two preview calls fail on one step. Then quote the error and switch. `; +const T3_CODE_DEVICE_TOOLS = ["device_list", "device_open", "device_screenshot", "device_close"]; + +const deviceToolInstructions = (loadTools: string) => `## T3 Code devices + +When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools} +1. Call \`device_list\` unless you already have the device's \`hostId\` and \`id\`. +2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its snapshots, screenshots and input need \`open \` first, and \`help \` lists the options. +3. Call \`device_screenshot\` to look at the screen. + +Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once, but never reuse or close another session; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; + +/** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */ +export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions(""); + +/** Claude Code defers MCP tools behind ToolSearch, so Claude's copy says how to load them. */ +export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH = deviceToolInstructions( + ` Load them with ToolSearch \`select:${T3_CODE_DEVICE_TOOLS.map((tool) => `mcp__t3-code__${tool}`).join(",")}\`.`, +); + // ACP only: some ACP agents accept the injected MCP server but never expose // its tools, so they need the terminal transport other harnesses never use. const T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS = `ACP fallback: if the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call them through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset for a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`.`; @@ -53,6 +72,7 @@ Investigate with read-only actions and do not edit files or otherwise execute th export interface T3AcpInstructionState { readonly interactionMode: ProviderInteractionMode; readonly hasT3Mcp: boolean; + readonly hasDeviceTools: boolean; } /** ACP lacks system/developer fields; inject context on first prompt and tool/mode changes. */ @@ -67,7 +87,8 @@ export function t3AcpPromptWithInstructions(input: { if (input.prompt.trimStart().startsWith("/")) return input.prompt; if ( input.previousState?.interactionMode === input.state.interactionMode && - input.previousState.hasT3Mcp === input.state.hasT3Mcp + input.previousState.hasT3Mcp === input.state.hasT3Mcp && + input.previousState.hasDeviceTools === input.state.hasDeviceTools ) { return input.prompt; } @@ -80,6 +101,7 @@ export function t3AcpPromptWithInstructions(input: { ? [input.t3ToolInstructions.trim()] : [ T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim(), + ...(input.state.hasDeviceTools ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS] : []), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(), T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS, ] @@ -88,20 +110,38 @@ export function t3AcpPromptWithInstructions(input: { return `\n${instructions.join("\n\n")}\n\n\n\n${input.prompt}\n`; } -function prependT3OrchestrationInstructions(prompt: string): string { - return `${T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()}\n\n\n${prompt}\n`; +const orchestrationInstructions = (hasDeviceTools: boolean) => + hasDeviceTools + ? `${T3_CODE_ORCHESTRATION_INSTRUCTIONS}\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n` + : T3_CODE_ORCHESTRATION_INSTRUCTIONS; + +function prependT3OrchestrationInstructions(prompt: string, instructions: string): string { + return `${instructions.trim()}\n\n\n${prompt}\n`; } -export function t3OrchestrationPromptForFirstRun(input: { +/** For providers whose instructions ride on the prompt, so a compacted conversation loses them. */ +export function t3OrchestrationPrompt(input: { readonly prompt: string; - readonly runOrdinal: number; readonly hasT3Mcp: boolean; + readonly hasDeviceTools: boolean; + readonly orchestrationDelivered: boolean; + readonly deviceInstructionsDelivered: boolean; }): string { - return input.runOrdinal === 1 && input.hasT3Mcp - ? prependT3OrchestrationInstructions(input.prompt) + if (!input.hasT3Mcp) return input.prompt; + if (!input.orchestrationDelivered) { + return prependT3OrchestrationInstructions( + input.prompt, + orchestrationInstructions(input.hasDeviceTools), + ); + } + return input.hasDeviceTools && !input.deviceInstructionsDelivered + ? prependT3OrchestrationInstructions(input.prompt, T3_CODE_DEVICE_TOOL_INSTRUCTIONS) : input.prompt; } -export function t3OrchestrationSystemPrompt(hasT3Mcp: boolean): string | undefined { - return hasT3Mcp ? T3_CODE_ORCHESTRATION_INSTRUCTIONS : undefined; +export function t3OrchestrationSystemPrompt( + hasT3Mcp: boolean, + hasDeviceTools: boolean, +): string | undefined { + return hasT3Mcp ? orchestrationInstructions(hasDeviceTools) : undefined; } diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index c9c877d29fe20..88f2c1f113848 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,4 +1,8 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderUsageLimitsUpdate, + ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; @@ -10,6 +14,7 @@ export interface ManagedServerProvider { }) => Effect.Effect; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ readonly applyUsageLimits: ( diff --git a/packages/shared/src/changedFileKind.ts b/packages/shared/src/changedFileKind.ts index 9fd5c0c7edc79..01cbe899b26ff 100644 --- a/packages/shared/src/changedFileKind.ts +++ b/packages/shared/src/changedFileKind.ts @@ -67,6 +67,11 @@ const NOT_DOCS_NAME = /^(requirements.*|constraints.*|CMakeLists|robots|llms)\.t * output count as generated, and test support (mocks, fixtures, snapshots) is kept apart from the * tests themselves, and prose (Markdown, plain text) is its own kind, so a reviewer can see how much of a change is logic. */ +export function isTestChangedFile(path: string): boolean { + const kind = classifyChangedFile(path); + return kind === "tests" || kind === "mocks"; +} + export function classifyChangedFile(path: string): ChangedFileKind { const segments = path.replaceAll("\\", "/").split("/"); const name = segments.pop() ?? ""; diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index f5fca1cf5f857..2cbd4e62f29af 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -412,7 +412,7 @@ describe("derivePendingBackgroundWork kinds", () => { }, { taskId: "pull-request-watch:github.com/acme/app#5", - description: "Watching pull request #5", + description: "Watching merge request !5", kind: "monitor", awaited: true, }, diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 97846939d31cc..f9a43b6123a1e 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -24,6 +24,24 @@ import { const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { + it("replaces and clears the fallback without carrying model options", () => { + const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" }; + const settings = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { + ...fallbackModelSelection, + model: "old", + options: [{ id: "effort", value: "high" }], + }, + }, + { fallbackModelSelection }, + ); + expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection); + expect( + applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection, + ).toBeNull(); + }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { const configured = ProviderInstanceId.make("codex_work"); const providerInstances = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 0c151eb5025af..e7c856070658a 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -92,13 +92,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( @@ -538,6 +540,9 @@ export function applyServerSettingsPatch( ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), + ...(patch.fallbackModelSelection !== undefined + ? { fallbackModelSelection: patch.fallbackModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 6d46cc6d14b9b..7e1ffa319bf09 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -182,6 +182,7 @@ export interface ProviderChangeRequestStat { } export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly maintainerCanModify?: boolean; readonly headSha?: string | null; readonly body: string; readonly changedFiles: number; @@ -448,6 +449,7 @@ export interface PullRequestProviderApi { /** Requires edit capability and at least one field. */ readonly updateChangeRequest?: ( input: ProviderRepositoryRef & { + readonly maintainerCanModify?: boolean | undefined; readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aef92d50446f..4ce6cc4291f0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,9 @@ importers: character-entities: specifier: 2.0.2 version: 2.0.2 + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.5 version: 4.0.5 @@ -385,7 +388,7 @@ importers: devDependencies: '@anthropic-ai/claude-agent-sdk': specifier: 0.3.276 - version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0)(zod@4.4.3) + version: 0.3.276(@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/sdk': specifier: 0.93.0 version: 0.93.0(zod@4.4.3) @@ -1652,6 +1655,7 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -5092,10 +5096,10 @@ ignoredOptionalDependencies: snapshots: - '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0)(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.276(@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 + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': @@ -5600,7 +5604,7 @@ snapshots: optionalDependencies: react-dom: 19.3.0(react@19.3.0) - '@modelcontextprotocol/sdk@1.29.0': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.13) ajv: 8.20.0 diff --git a/scripts/lib/third-party-licenses.test.ts b/scripts/lib/third-party-licenses.test.ts index c7fc73d58dfef..7b9a6d707af72 100644 --- a/scripts/lib/third-party-licenses.test.ts +++ b/scripts/lib/third-party-licenses.test.ts @@ -4,10 +4,11 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; -import { generateThirdPartyLicenseManifest } from "./third-party-licenses.ts"; +import { generateThirdPartyLicenseManifest } from "./third-party-licenses.js"; +// Coder: device tools are not carried, so neither is the installer-pin notice test. const tempDirectories: string[] = []; async function writeJson(path: string, value: unknown): Promise { @@ -60,7 +61,6 @@ async function createFixture(): Promise<{ } afterEach(async () => { - vi.unstubAllGlobals(); await Promise.all( tempDirectories .splice(0) @@ -101,13 +101,9 @@ describe("third-party license generation", () => { }); }); - it("renders generated notices from bundled SPDX text without network access", async () => { - const fetch = vi.fn(() => { - throw new Error("Network is unavailable"); - }); - vi.stubGlobal("fetch", fetch); + it("renders generated notices from the committed SPDX templates", async () => { const fixture = await createFixture(); - await writeJson(NodePath.join(fixture.root, "licenses/spdx/v3.28.0/MIT.json"), { + await writeJson(NodePath.join(fixture.root, "third-party-licenses/spdx/v3.28.0/MIT.json"), { licenseId: "MIT", licenseText: "MIT License\n\nCopyright (c) \n\nPermission text", }); @@ -137,33 +133,6 @@ describe("third-party license generation", () => { expect(manifest.entries.find((entry) => entry.name === "generated-asset")?.noticeText).toBe( "Adapted for T3 Code.\n\nMIT License\n\nCopyright (c) 2026 Example Author\n\nPermission text", ); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("fails with an actionable error for missing production notices without fetching", async () => { - const fetch = vi.fn(() => { - throw new Error("Network is unavailable"); - }); - vi.stubGlobal("fetch", fetch); - const fixture = await createFixture(); - await writeJson(fixture.configFile, { - customNotices: [ - { - name: "generated-asset", - license: "MIT", - generatedNotices: [{ licenseId: "MIT" }], - bundles: ["assets", "web"], - }, - ], - packageOverrides: [], - }); - await expect( - generateThirdPartyLicenseManifest({ - configFile: fixture.configFile, - packageManifests: [{ bundle: "web", path: fixture.appManifest }], - }), - ).rejects.toThrow("Missing bundled SPDX license MIT. Run pnpm licenses:sync"); - expect(fetch).not.toHaveBeenCalled(); }); it("finds packages whose exports hide both their manifest and entry point", async () => { @@ -331,10 +300,10 @@ describe("third-party license generation", () => { const fixture = await createFixture(); await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); for (const licenseId of ["Apache-2.0", "BSD-3-Clause"]) { - await writeJson(NodePath.join(fixture.root, `licenses/spdx/v3.28.0/${licenseId}.json`), { - licenseId, - licenseText: `${licenseId} terms`, - }); + await writeJson( + NodePath.join(fixture.root, `third-party-licenses/spdx/v3.28.0/${licenseId}.json`), + { licenseId, licenseText: `${licenseId} terms` }, + ); } await writeJson(fixture.configFile, { packageOverrides: [ diff --git a/scripts/lib/third-party-licenses.ts b/scripts/lib/third-party-licenses.ts index 1bc20f5823242..591e0c247954b 100644 --- a/scripts/lib/third-party-licenses.ts +++ b/scripts/lib/third-party-licenses.ts @@ -10,9 +10,7 @@ import type { Plugin } from "vite-plus"; const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json"; const SPDX_LICENSE_LIST_VERSION = "v3.28.0"; const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c"; -// Coder: builds never fetch. SPDX texts are committed under licenses/spdx and only -// `pnpm licenses:sync` downloads missing ones. -const SPDX_NOTICE_DIRECTORY = "licenses/spdx"; +const SPDX_TEMPLATE_DIRECTORY = "third-party-licenses/spdx"; export interface ThirdPartyLicenseEntry { readonly bundles: ReadonlyArray; @@ -314,10 +312,10 @@ async function readConfig(configFile: string | URL | undefined): Promise<{ }; } -function spdxLicensePath(configDirectory: string, licenseId: string): string { +function spdxTemplatePath(configDirectory: string, licenseId: string): string { return NodePath.join( configDirectory, - SPDX_NOTICE_DIRECTORY, + SPDX_TEMPLATE_DIRECTORY, SPDX_LICENSE_LIST_VERSION, `${licenseId}.json`, ); @@ -335,12 +333,12 @@ function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): Sp return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() }; } -async function readLocalSpdxLicense( +async function readSpdxLicense( configDirectory: string, licenseId: string, ): Promise { try { - const source = await NodeFSP.readFile(spdxLicensePath(configDirectory, licenseId), "utf8"); + const source = await NodeFSP.readFile(spdxTemplatePath(configDirectory, licenseId), "utf8"); return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId); } catch (error) { const code = isRecord(error) && typeof error.code === "string" ? error.code : null; @@ -349,10 +347,7 @@ async function readLocalSpdxLicense( } } -async function downloadSpdxLicense( - configDirectory: string, - licenseId: string, -): Promise { +async function downloadSpdxLicense(configDirectory: string, licenseId: string): Promise { const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`; const response = await fetch(url); if (!response.ok) { @@ -361,22 +356,9 @@ async function downloadSpdxLicense( ); } const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId); - const cachePath = spdxLicensePath(configDirectory, licenseId); - await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true }); - await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8"); - return details; -} - -async function resolveSpdxLicense( - configDirectory: string, - licenseId: string, - allowMissing: boolean, -): Promise { - const local = await readLocalSpdxLicense(configDirectory, licenseId); - if (local || allowMissing) return local; - throw new Error( - `Missing bundled SPDX license ${licenseId}. Run pnpm licenses:sync with network access and commit the updated licenses/spdx files.`, - ); + const templatePath = spdxTemplatePath(configDirectory, licenseId); + await NodeFSP.mkdir(NodePath.dirname(templatePath), { recursive: true }); + await NodeFSP.writeFile(templatePath, `${JSON.stringify(details, null, 2)}\n`, "utf8"); } function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string { @@ -401,17 +383,19 @@ function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: async function generatedNoticeText( configs: ReadonlyArray, configDirectory: string, - allowMissing: boolean, -): Promise { +): Promise { const sections = await Promise.all( configs.map(async (config) => { - const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing); - return details ? renderGeneratedNotice(config, details.licenseText) : null; + const details = await readSpdxLicense(configDirectory, config.licenseId); + if (!details) { + throw new Error( + `SPDX license template ${config.licenseId} is missing. Run \`vp run licenses:sync\` and commit ${SPDX_TEMPLATE_DIRECTORY}.`, + ); + } + return renderGeneratedNotice(config, details.licenseText); }), ); - return sections.some((section) => section === null) - ? null - : (sections as ReadonlyArray).join("\n\n---\n\n"); + return sections.join("\n\n---\n\n"); } function configuredGeneratedNotices( @@ -432,7 +416,7 @@ async function syncConfiguredGeneratedNotices( ].sort((left, right) => left.localeCompare(right)); await Promise.all( licenseIds.map(async (licenseId) => { - if (!(await readLocalSpdxLicense(directory, licenseId))) { + if (!(await readSpdxLicense(directory, licenseId))) { await downloadSpdxLicense(directory, licenseId); } }), @@ -854,8 +838,7 @@ async function packageEntry( configDirectory: string, packageNotices: Map>, repositoryNotices: ReadonlyMap, - allowMissingGeneratedNotices: boolean, -): Promise { +): Promise { const name = typeof collected.packageJson.name === "string" ? collected.packageJson.name @@ -877,11 +860,7 @@ async function packageEntry( const repositoryKey = repositoryNoticeKey(collected.packageJson, license); const noticeText = override?.generatedNotices - ? await generatedNoticeText( - override.generatedNotices, - configDirectory, - allowMissingGeneratedNotices, - ) + ? await generatedNoticeText(override.generatedNotices, configDirectory) : override?.noticeFile ? ( await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8") @@ -889,7 +868,6 @@ async function packageEntry( : ((await packageNoticeText(collected.packageRoot, packageNotices)) ?? (repositoryKey ? repositoryNotices.get(repositoryKey) : undefined)); if (!noticeText) { - if (override?.generatedNotices && allowMissingGeneratedNotices) return null; throw new Error( `${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`, ); @@ -910,7 +888,6 @@ async function customEntries( config: ThirdPartyLicensesConfig, configDirectory: string, includedBundles: ReadonlySet, - allowMissingGeneratedNotices: boolean, ): Promise> { const entries = await Promise.all( config.customNotices @@ -923,11 +900,7 @@ async function customEntries( ) .map(async (notice) => { const noticeText = notice.generatedNotices - ? await generatedNoticeText( - notice.generatedNotices, - configDirectory, - allowMissingGeneratedNotices, - ) + ? await generatedNoticeText(notice.generatedNotices, configDirectory) : ( await Promise.all( notice.noticeFiles!.map(async (noticeFile) => { @@ -941,10 +914,6 @@ async function customEntries( }), ) ).join("\n\n---\n\n"); - if (noticeText === null) { - if (allowMissingGeneratedNotices) return null; - throw new Error(`Could not generate custom third-party notice "${notice.name}".`); - } if (noticeText.length === 0) { throw new Error(`Custom third-party notice "${notice.name}" is empty.`); } @@ -962,7 +931,7 @@ async function customEntries( }; }), ); - return entries.flatMap((entry) => (entry ? [entry] : [])); + return entries; } function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number { @@ -991,7 +960,6 @@ export async function generateThirdPartyLicenseManifest(input: { readonly packageManifests: ReadonlyArray; readonly bundledModuleIds?: ReadonlyArray; readonly bundleName?: string; - readonly allowMissingGeneratedNotices?: boolean; }): Promise { const [{ config, directory }, collection] = await Promise.all([ readConfig(input.configFile), @@ -1006,14 +974,7 @@ export async function generateThirdPartyLicenseManifest(input: { const packageEntryResults = await Promise.allSettled( [...collection.byIdentity.values()].map((collected) => - packageEntry( - collected, - config, - directory, - packageNotices, - repositoryNotices, - input.allowMissingGeneratedNotices ?? false, - ), + packageEntry(collected, config, directory, packageNotices, repositoryNotices), ), ); const failures = packageEntryResults.flatMap((result) => @@ -1027,16 +988,11 @@ export async function generateThirdPartyLicenseManifest(input: { ); } const packageEntries = packageEntryResults.flatMap((result) => - result.status === "fulfilled" && result.value ? [result.value] : [], + result.status === "fulfilled" ? [result.value] : [], ); const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle)); if (input.bundleName) includedBundles.add(input.bundleName); - const manualEntries = await customEntries( - config, - directory, - includedBundles, - input.allowMissingGeneratedNotices ?? false, - ); + const manualEntries = await customEntries(config, directory, includedBundles); const entries = [...packageEntries, ...manualEntries].sort(entrySort); assertUniqueEntries(entries); return { @@ -1072,7 +1028,6 @@ export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOption manifestPromise ??= generateThirdPartyLicenseManifest({ packageManifests: options.packageManifests, bundleName: options.bundleName, - allowMissingGeneratedNotices: true, ...(options.configFile !== undefined ? { configFile: options.configFile } : {}), }).catch((error: unknown) => { manifestPromise = null; diff --git a/scripts/sync-resolve-deleted.mjs b/scripts/sync-resolve-deleted.mjs index 40ce6e8fa22e5..1cf1aa7449fd9 100644 --- a/scripts/sync-resolve-deleted.mjs +++ b/scripts/sync-resolve-deleted.mjs @@ -25,6 +25,7 @@ const DELETED_PATHS = { "apps/web/src/routes/pair.tsx", "apps/web/src/state/auth.ts", "packages/client-runtime/src/authorization/", + "packages/client-runtime/src/state/commandPermissions.test.ts", "packages/client-runtime/src/state/auth.ts", "packages/shared/src/authScopeOptions.ts", "packages/shared/src/connectAuth.ts", @@ -41,6 +42,7 @@ const DELETED_PATHS = { "apps/server/src/preview/", "apps/web/src/browser/", "apps/web/src/components/preview/", + "apps/web/src/components/settings/IntegrationsSettings.tsx", "apps/web/src/previewMiniPlayerStore.ts", "apps/web/src/previewStateStore.ts", "apps/web/src/state/preview.ts", @@ -53,9 +55,15 @@ const DELETED_PATHS = { // Device: no device hosts, device hub, or agent device shim. device: [ "apps/server/src/device/", + "apps/server/src/mcp/McpDeviceToolkit.test.ts", "apps/server/src/mcp/toolkits/device/", "apps/web/src/components/device/", + "apps/web/src/components/settings/EasDeviceHostEditor.tsx", + "apps/web/src/components/settings/easDeviceHostsSettings.logic.ts", "apps/web/src/state/device.ts", + "docs/internals/devices.md", + "docs/user/devices.md", + "packages/contracts/src/device.test.ts", "packages/client-runtime/src/device/", "packages/client-runtime/src/state/device.ts", ], @@ -66,6 +74,7 @@ const DELETED_PATHS = { "apps/server/src/http.ts", "apps/server/src/httpCors.ts", "apps/server/src/mcp/McpHttpServer.ts", + "apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts", "apps/server/src/orchestration-v2/http.ts", "apps/server/src/project/http.ts", "apps/server/src/pullRequest/http.ts", @@ -86,6 +95,13 @@ const DELETED_PATHS = { "apps/server/scripts/record-fx-acp-replay-fixture.ts", "apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts", "apps/server/src/orchestration-v2/Adapters/FxAdapterV2.ts", + "apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts", + "apps/server/src/provider/AntigravityInstallation.ts", + "apps/server/src/provider/AntigravityProvider.ts", + "apps/server/src/provider/Drivers/AntigravityDriver.ts", + "apps/server/src/provider/antigravityRelease.ts", + "apps/server/src/textGeneration/AntigravityTextGeneration.ts", + "docs/user/providers-antigravity.md", "apps/server/src/provider/acp/", "packages/provider-cursor/", "packages/provider-fx/", @@ -115,14 +131,17 @@ const DELETED_PATHS = { "apps/desktop/", "apps/server/src/desktopUpdate/", "apps/web/src/components/desktop/", + "apps/web/src/components/chat/OpenInPicker.tsx", + "apps/web/src/components/chat/OpenInPickerShortcut.ts", "apps/web/src/components/permissions/", "apps/web/src/environments/", "assets/", "native/", + "packages/contracts/src/ipc.ts", "packaging/", ], // Mobile: no mobile app. - mobile: ["apps/mobile/"], + mobile: ["apps/mobile/", "patches/@expo__ui@58.0.20.patch"], // Hosted web: no hosted app, cloud accounts, marketing site, or welcome onboarding. "hosted web": [ "apps/marketing/", @@ -132,29 +151,63 @@ const DELETED_PATHS = { "apps/web/src/components/cloud/", "apps/web/src/components/onboarding/", ], - // Server CLI: the helper starts over `coder ssh`, never as upstream's `t3` command. - "server CLI": ["apps/server/src/cli/"], + // Server CLI: the helper starts over `coder ssh`, never as upstream's `t3` command, and + // CoderEnvironment replaces upstream's ServerEnvironment. + "server CLI": ["apps/server/src/cli/", "apps/server/src/environment/ServerEnvironment.ts"], // Relay: no relay, Tailscale, or SSH transport. relay: [ "apps/server/src/relay/", "apps/web/src/state/relay.ts", + "docs/internals/remote.md", + "docs/user/remote-access.md", "infra/", "packages/client-runtime/src/relay/", "packages/ssh/", "packages/tailscale/", ], - // Telemetry: no T3-owned telemetry, usage dashboards, diagnostics, or trace export. + // Telemetry: no T3-owned telemetry, usage dashboards or plan-window usage limits, diagnostics, + // or trace export. telemetry: [ "apps/server/src/diagnostics/", + "apps/server/src/observability/RpcInstrumentation.ts", "apps/server/src/resourceTelemetry/", "apps/server/src/telemetry/", "apps/server/src/usage/", "apps/web/src/components/usage/", + "apps/web/src/components/chat/ComposerUsageLimits.tsx", "apps/web/src/observability/", "apps/web/src/state/usage.ts", "packages/client-runtime/src/state/usage.ts", "packages/contracts/src/resourceTelemetry.ts", "packages/contracts/src/usage.ts", + "packages/shared/src/usageLimits.ts", + "docs/user/usage.md", + ], + // Updates and releases: no server self-update, update pill or fleet tracks, npm launcher, CLI + // archives, or release notes. + "updates and releases": [ + "apps/server/src/persistence/databaseMigrationHead.ts", + "apps/web/src/components/ServerUpdateAction.tsx", + "apps/web/src/components/sidebar/SidebarUpdatePill.tsx", + "apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx", + "apps/web/src/components/sidebar/UpdateEverythingDialog.tsx", + "apps/web/src/components/sidebar/updateEverything.logic.ts", + "apps/web/src/components/sidebar/useUpdateEverything.ts", + "apps/web/src/state/npmRelease.ts", + "docs/user/updating.md", + "packages/shared/src/cliRelease.ts", + "scripts/build-cli-archive.ts", + "scripts/build-npm-launcher.ts", + "scripts/generate-release-notes.ts", + "scripts/licenses/libatomic.LICENSE", + "scripts/notify-discord-release.ts", + "scripts/smoke-cli-archive.ts", + ], + // Peer links: the gateway hub replaces upstream's peer stack, so there is no link request card + // or client peer-link state. + "peer links": [ + "apps/web/src/components/chat/LinkRequestCard.tsx", + "packages/client-runtime/src/state/peerLinks.ts", ], // Guest sharing: no guest share links, guest page, or guest HTTP routes. "guest sharing": [ @@ -200,6 +253,11 @@ const DELETED_PATHS = { "apps/server/src/provider/agentContextBudget.json", "apps/web/scripts/verify-built-boot.ts", "docs/operations/", + "docs/internals/open-source-licenses.md", + "docs/user/faq.md", + "scripts/check-changed.ts", + // Upstream's ChatMarkdown browser test, which the fork has not carried since its first commit. + "apps/web/src/components/ChatMarkdown.browser.test.tsx", // CodeRabbit's configuration and upstream's MCP tool-use evals. ".coderabbit.config.ts", "evals/", diff --git a/licenses/spdx/v3.28.0/Apache-2.0.json b/third-party-licenses/spdx/v3.28.0/Apache-2.0.json similarity index 100% rename from licenses/spdx/v3.28.0/Apache-2.0.json rename to third-party-licenses/spdx/v3.28.0/Apache-2.0.json diff --git a/licenses/spdx/v3.28.0/BSD-2-Clause.json b/third-party-licenses/spdx/v3.28.0/BSD-2-Clause.json similarity index 100% rename from licenses/spdx/v3.28.0/BSD-2-Clause.json rename to third-party-licenses/spdx/v3.28.0/BSD-2-Clause.json diff --git a/licenses/spdx/v3.28.0/BSD-3-Clause.json b/third-party-licenses/spdx/v3.28.0/BSD-3-Clause.json similarity index 100% rename from licenses/spdx/v3.28.0/BSD-3-Clause.json rename to third-party-licenses/spdx/v3.28.0/BSD-3-Clause.json diff --git a/licenses/spdx/v3.28.0/CC0-1.0.json b/third-party-licenses/spdx/v3.28.0/CC0-1.0.json similarity index 100% rename from licenses/spdx/v3.28.0/CC0-1.0.json rename to third-party-licenses/spdx/v3.28.0/CC0-1.0.json diff --git a/licenses/spdx/v3.28.0/ISC.json b/third-party-licenses/spdx/v3.28.0/ISC.json similarity index 100% rename from licenses/spdx/v3.28.0/ISC.json rename to third-party-licenses/spdx/v3.28.0/ISC.json diff --git a/licenses/spdx/v3.28.0/MIT.json b/third-party-licenses/spdx/v3.28.0/MIT.json similarity index 100% rename from licenses/spdx/v3.28.0/MIT.json rename to third-party-licenses/spdx/v3.28.0/MIT.json diff --git a/licenses/spdx/v3.28.0/OFL-1.1.json b/third-party-licenses/spdx/v3.28.0/OFL-1.1.json similarity index 100% rename from licenses/spdx/v3.28.0/OFL-1.1.json rename to third-party-licenses/spdx/v3.28.0/OFL-1.1.json diff --git a/licenses/spdx/v3.28.0/Unlicense.json b/third-party-licenses/spdx/v3.28.0/Unlicense.json similarity index 100% rename from licenses/spdx/v3.28.0/Unlicense.json rename to third-party-licenses/spdx/v3.28.0/Unlicense.json diff --git a/vite.config.ts b/vite.config.ts index 7566e22670037..44a17d1ea2dee 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,11 @@ export default defineConfig({ ], }, staged: { - "*": "vp fmt --no-error-on-unmatched-pattern", + // In order, so lint reads the formatted file. CI lint failures cost a full round trip. + "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"], + // knip checks the whole repo, so it takes no file list. Any code or manifest change can leave + // a file, export, or dependency unused. + "{*.{js,jsx,ts,tsx,mjs,cjs,mts,cts},package.json,knip.jsonc}": () => "vp run knip:check", }, fmt: { ignorePatterns: [