diff --git a/apps/server/package.json b/apps/server/package.json index a610117eb1575..9a27e572b7b55 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "type": "module", "scripts": { diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 6cf695e948550..8bb226018940e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -58,8 +58,10 @@ const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION = const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); +const terminalOutputBytes = Number(process.env.T3_ACP_EMIT_TERMINAL_OUTPUT_BYTES ?? "0"); const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; const supportsAcpMcp = process.env.T3_ACP_MCP_ACP === "1"; +const supportsHttpMcp = process.env.T3_ACP_MCP_HTTP === "1"; const supportsV2Management = process.env.T3_ACP_V2_MANAGEMENT === "1"; const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); const initializeAuthMethodId = @@ -505,7 +507,14 @@ const program = Effect.gen(function* () { session: { ...(supportsSessionLifecycle ? { fork: {}, additionalDirectories: {} } : {}), ...(supportsV2Management ? { delete: {} } : {}), - ...(supportsAcpMcp ? { mcp: { acp: {} } } : {}), + ...(supportsAcpMcp || supportsHttpMcp + ? { + mcp: { + ...(supportsAcpMcp ? { acp: {} } : {}), + ...(supportsHttpMcp ? { http: {} } : {}), + }, + } + : {}), }, ...(supportsV2Management ? { providers: {} } : {}), }, @@ -1020,6 +1029,36 @@ const program = Effect.gen(function* () { return yield* finishPrompt(requestedSessionId, "end_turn"); } + if (terminalOutputBytes > 0) { + const terminalId = "long-output"; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "terminal_update", terminalId, command: "print long output" }, + }); + // 4 KiB chunks of 64-char numbered lines, like a long build log. + for (let line = 0; line * 64 < terminalOutputBytes; line += 64) { + let chunk = ""; + for (let i = line + 1; i <= line + 64; i++) { + chunk += `${String(i).padStart(8, "0")} ${"x".repeat(54)}\n`; + } + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "terminal_output_chunk", + terminalId, + data: Buffer.from(chunk) + .subarray(0, terminalOutputBytes - line * 64) + .toString("base64"), + }, + }); + } + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "terminal_update", terminalId, exitStatus: { exitCode: 0 } }, + }); + return yield* finishPrompt(requestedSessionId, "end_turn"); + } + if (emitNotice) { // A notice in fx 0.0.14's shape for a skipped skill, between two chunks of one message. const chunk = (text: string) => diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 1c9a24ff03cb3..becbe3ba66b5e 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -631,6 +631,51 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.skipIf(!symlinksSupported)("does not issue URLs for server state files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const stateFile = path.join(config.secretsDir, "example.bin"); + yield* fs.makeDirectory(config.secretsDir, { recursive: true }); + yield* fs.writeFile(stateFile, new Uint8Array(32).fill(7)); + yield* fs.writeFileString(config.dbPath, "SQLite format 3"); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-state-" }); + const alias = path.join(root, "alias"); + yield* fs.symlink(stateFile, alias); + const threadId = ThreadId.make("thread-1"); + for (const resource of [ + { _tag: "media-file", threadId, path: stateFile, disposition: "attachment" }, + { _tag: "media-file", threadId, path: alias, disposition: "attachment" }, + { + _tag: "media-file", + threadId, + path: path.join(config.secretsDir, "..", "secrets", "example.bin"), + disposition: "attachment", + }, + { _tag: "media-file", threadId, path: config.dbPath }, + { _tag: "draft-workspace-file", cwd: config.stateDir, path: "statev2.sqlite" }, + ] as const) { + const error = yield* issueAssetUrl({ resource }).pipe(Effect.flip); + expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError"); + } + + const icon = path.join(config.baseDir, "icon.png"); + yield* fs.symlink(stateFile, icon); + yield* fs.writeFileString(path.join(config.baseDir, "page.html"), ''); + const page = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: config.baseDir, path: "page.html" }, + }); + const pageToken = page.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length).split("/")[0]!; + expect(yield* resolveAsset(pageToken, "icon.png")).toBeNull(); + const faviconError = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: icon, + }).pipe(Effect.flip); + expect(faviconError._tag).toBe("AssetProjectFaviconNotFoundError"); + }).pipe(Effect.provide(layerTest)), + ); + it.effect.skipIf(!symlinksSupported)( "binds media URLs to the canonical target and rejects symlink substitution", () => diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 5092b31455eed..dc28916b446fc 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -53,6 +53,7 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; @@ -274,7 +275,9 @@ const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(funct if (Option.isNone(canonicalFile)) return null; const info = yield* ProjectFaviconResolver.optionOnNotFound(fileSystem.stat(canonicalFile.value)); - return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; + if (Option.isNone(info) || info.value.type !== "File") return null; + const isServerInternalPath = yield* makeIsServerInternalPath; + return (yield* isServerInternalPath(canonicalFile.value)) ? null : canonicalFile.value; }); const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( @@ -302,7 +305,9 @@ const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWor const info = yield* ProjectFaviconResolver.optionOnNotFound( fileSystem.stat(canonicalFile.value), ); - return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; + if (Option.isNone(info) || info.value.type !== "File") return null; + const isServerInternalPath = yield* makeIsServerInternalPath; + return (yield* isServerInternalPath(canonicalFile.value)) ? null : canonicalFile.value; }, ); diff --git a/apps/server/src/auth/serverInternalPaths.ts b/apps/server/src/auth/serverInternalPaths.ts new file mode 100644 index 0000000000000..0c0cc295eda09 --- /dev/null +++ b/apps/server/src/auth/serverInternalPaths.ts @@ -0,0 +1,29 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect's realPath keeps the requested letter case. +import * as NodeFSP from "node:fs/promises"; + +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import { isWithinRoot } from "../workspace/KnownWorkspaces.ts"; + +/** Builds a check for the server's own state files, which client file reads do not serve. */ +export const makeIsServerInternalPath = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + // Native realpath returns the on-disk letter case. + const realPath = (value: string) => + Effect.promise(() => NodeFSP.realpath(value).catch(() => path.resolve(value))); + return Effect.fn("ServerInternalPaths.isServerInternalPath")(function* (filePath: string) { + const [file, stateDir, secretsDir] = yield* Effect.all( + [realPath(filePath), realPath(config.stateDir), realPath(config.secretsDir)], + { concurrency: "unbounded" }, + ); + return ( + isWithinRoot(path, file, secretsDir) || + (path.dirname(file) === stateDir && path.basename(file).includes(".sqlite")) || + // macOS does not resolve `/dev/fd/N` to the open file's path. + isWithinRoot(path, file, "/dev/fd") + ); + }); +}); diff --git a/apps/server/src/coderEnvironment.ts b/apps/server/src/coderEnvironment.ts index 8cc73c511c93d..abf4e13d6c082 100644 --- a/apps/server/src/coderEnvironment.ts +++ b/apps/server/src/coderEnvironment.ts @@ -59,6 +59,7 @@ export const CODER_ENVIRONMENT_CAPABILITIES = { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, pullRequestChecks: true, + sideChats: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, @@ -91,6 +92,7 @@ export const CODER_ENVIRONMENT_CAPABILITIES = { localServerStop: true, environmentIcon: true, projectCloneTracking: true, + projectOrganization: true, threadProjectFromScratch: true, // Links are the gateway's workspace link rules; calls between workspaces go through its hub. peerLinks: true, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 665fc9030eab2..e8a921cb63141 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2707,6 +2707,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; readonly setPermissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["setPermissionMode"]; + readonly applyFlagSettings?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"]; readonly stopTask?: ( taskId: string, ) => Effect.Effect; @@ -2854,7 +2855,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: (settings) => Effect.sync(() => { liveSelectionChanges.push(settings); - }), + }).pipe( + Effect.andThen(() => options?.applyFlagSettings?.(settings) ?? Effect.void), + ), setPermissionMode: (mode) => Effect.sync(() => { permissionModeChanges.push(mode); @@ -4445,6 +4448,66 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { + content: { type: "advisor_tool_result_error", error_code: "overloaded" }, + status: "failed", + }, + { content: { type: "advisor_result", text: "Looks right." }, status: "completed" }, + ] as const)("settles an advisor call from its result content: $status", ({ content, status }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("advisor-result"), + text: "Check the plan with the advisor", + attachments: [], + }), + ); + const id = "srvtoolu_advisor"; + const assistantFrame = (uuid: string, block: unknown) => + claudeSdkFrame({ + type: "assistant", + uuid, + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "advisor-message", + type: "message", + role: "assistant", + model: "claude-opus-5-5", + content: [block], + }, + }); + yield* harness.offerAndWait( + assistantFrame("advisor-call", { type: "server_tool_use", id, name: "advisor", input: {} }), + ); + yield* harness.offerAndWait( + assistantFrame("advisor-result", { type: "advisor_tool_result", tool_use_id: id, content }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "advisor-terminal", result: "Checked" }), + ); + yield* Queue.take(harness.terminalReceipts); + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + assert.equal(items.at(-1)?.toolName, "advisor"); + assert.equal(items.at(-1)?.status, status); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + describe("MCP elicitations", () => { const testLayer = Layer.mergeAll( IdAllocator.layer, @@ -4686,7 +4749,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.runtime.startTurn(turn(4, { ...normal, model: "claude-sonnet-4-6" })); yield* settle; assert.deepEqual(harness.liveSelectionChanges, [ - { model: "claude-opus-5-5" }, { effortLevel: "xhigh", fastMode: true, ultracode: true }, { model: "claude-sonnet-4-6" }, { effortLevel: "high", fastMode: null, ultracode: null }, @@ -4711,7 +4773,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { } assert.strictEqual(harness.getOpenedOptions(), originalOptions); assert.lengthOf(harness.offeredMessages, 4); - assert.lengthOf(harness.liveSelectionChanges, 4); + assert.lengthOf(harness.liveSelectionChanges, 3); assert.isTrue(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( @@ -4721,6 +4783,64 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("re-sends the model on the turn after a live selection change fails", () => + Effect.scoped( + Effect.gen(function* () { + let failFlags = true; + const harness = yield* makeWakeHarnessWithOptions({ + applyFlagSettings: () => + Effect.suspend(() => { + if (!failFlags) return Effect.void; + failFlags = false; + return Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "applyFlagSettings", + cause: new Error("control request failed"), + }), + ); + }), + }); + const now = yield* DateTime.now; + const opus = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const turn = (ordinal: number, modelSelection: ModelSelection) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-failed-live-change:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection, + }); + yield* harness.runtime.startTurn(turn(1, opus)); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + const failed = yield* harness.runtime + .startTurn(turn(2, { ...opus, model: "claude-sonnet-4-6" })) + .pipe(Effect.result); + assert.equal(failed._tag, "Failure"); + + // The process may now run Sonnet, so returning to Opus must send it. + yield* harness.runtime.startTurn(turn(3, opus)); + assert.deepEqual(harness.liveSelectionChanges, [ + { model: "claude-sonnet-4-6" }, + { effortLevel: "high", fastMode: null, ultracode: null }, + { model: "claude-opus-5-5" }, + { effortLevel: "medium", fastMode: false, ultracode: null }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each(["completed", "interrupted"] as const)( "projects Claude thinking blocks when %s", (status) => @@ -4983,11 +5103,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each( (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => - [true, false].map((steered) => ({ terminalReason, steered })), + [ + { steered: false, queryExits: false }, + { steered: true, queryExits: false }, + { steered: true, queryExits: true }, + ].map((scenario) => ({ terminalReason, ...scenario })), ), - )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) => + )("handles $terminalReason (steered=$steered, exits=$queryExits)", (scenario) => Effect.scoped( Effect.gen(function* () { + const { terminalReason, steered, queryExits } = scenario; const harness = yield* makeWakeHarness; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attemptId = RunAttemptId.make("attempt-steering-abort"); @@ -5019,8 +5144,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); assert.equal(harness.offeredMessages[1]?.priority, "now"); } - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000901", result: "", @@ -5033,7 +5157,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, }), ); - if (steered) { + if (queryExits) { + yield* Queue.shutdown(harness.sdkMessages); + } else if (steered) { yield* Queue.offer(harness.sdkMessages, wakeAssistant); yield* Queue.offer( harness.sdkMessages, @@ -5050,16 +5176,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, steered ? "completed" : "interrupted"); + assert.equal( + terminal.status, + queryExits ? "failed" : steered ? "completed" : "interrupted", + ); if (steered) { - assert.isTrue( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === WAKE_ASSISTANT_TEXT, - ), - ); + if (!queryExits) + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === WAKE_ASSISTANT_TEXT, + ), + ); const completed = harness.events.findLast( (event) => event.type === "provider_turn.updated", ); @@ -5069,12 +5199,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { : undefined, { usageScope: "main_agent", - usageStatus: "complete", + usageStatus: queryExits ? "partial" : "complete", hasSubagents: false, - inputTokens: 603 + 2847 + 11455, - outputTokens: 46, - cachedInputTokens: 11455, - cacheCreationTokens: 2847, + inputTokens: queryExits ? 3100 : 603 + 2847 + 11455, + outputTokens: queryExits ? 40 : 46, + cachedInputTokens: queryExits ? 2000 : 11455, + cacheCreationTokens: queryExits ? 500 : 2847, }, ); } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 162946c0a7e6d..b4eb808da7c5a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3079,6 +3079,7 @@ function isClaudeTypedToolResultErrorContent(content: ClaudeTypedToolResultConte } switch (content.type) { + case "advisor_tool_result_error": case "bash_code_execution_tool_result_error": case "code_execution_tool_result_error": case "text_editor_code_execution_tool_result_error": @@ -10015,7 +10016,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing.openedPermissionMode === queryPolicy.permissionMode) ) { const live = existing; - yield* live.query.setModel(compiledSelection.apiModelId).pipe( + // Preserve launcher-selected models unless the model changed or a failed update left it unknown. + yield* ( + live.selection.queryIdentity === "" || + live.selection.apiModelId !== compiledSelection.apiModelId + ? live.query.setModel(compiledSelection.apiModelId) + : Effect.void + ).pipe( Effect.andThen( live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)), ), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 2f7d418df0d9d..dd64c31c24174 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -42,6 +42,7 @@ import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; +import { toolOutputImages } from "@t3tools/shared/toolOutput"; import { resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -8404,5 +8405,93 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ); + + it.effect("classifies permission requests that only list sandbox entries", () => + Effect.gen(function* () { + const permissionRequest = ( + id: number, + itemId: string, + entries: CodexSchema.PermissionsRequestApprovalParams__AdditionalFileSystemPermissions["entries"], + ): Array => [ + { + type: "emit_inbound", + label: `permissions ${itemId}`, + frame: { + id, + method: "item/permissions/requestApproval", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + itemId, + cwd: "/w", + startedAtMs: 2, + permissions: { fileSystem: { entries } }, + }, + }, + }, + { + type: "expect_outbound", + label: `permissions ${itemId}`, + frame: { id, result: { permissions: { fileSystem: { entries } }, scope: "turn" } }, + }, + ]; + const { harness, answerNext } = yield* startApprovalTurn("permission-entries", [ + ...permissionRequest(0, "write-glob", [ + { access: "read", path: { type: "path", path: "/w/src" } }, + { access: "write", path: { type: "glob_pattern", pattern: "/w/out/**" } }, + ]), + ...permissionRequest(1, "read-glob", [ + { access: "read", path: { type: "glob_pattern", pattern: "/w/docs/**" } }, + ]), + turnCompleted(nativeTurnId), + ]); + const write = yield* answerNext; + const read = yield* answerNext; + yield* harness.firstTerminal; + + assert.deepEqual( + [write, read].map((request) => [ + request.kind, + approvalItem(harness.events, request)?.requestKind, + ]), + [ + ["file-change", "file-change"], + ["file-read", "file-read"], + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); +}); + +describe("codexDynamicToolOutput", () => { + it("translates inputImage data URLs into image blocks the work log can serve", () => { + const output = CodexAdapterV2.codexDynamicToolOutput({ + type: "dynamicToolCall", + id: "exec-image", + tool: "exec", + status: "completed", + arguments: {}, + contentItems: [ + { type: "inputText", text: "Script completed" }, + { type: "inputImage", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }, + { type: "inputImage", imageUrl: "https://example.test/remote.png" }, + { type: "inputImage", imageUrl: "data:image/png;base64," }, + { type: "inputAudio", audioUrl: "data:audio/wav;base64,AAAA" }, + ], + }); + assert.deepEqual(toolOutputImages(output), [{ mimeType: "image/png", data: "iVBORw0KGgo=" }]); + assert.deepEqual(output, [ + { type: "inputText", text: "Script completed" }, + { type: "image", mimeType: "image/png", data: "iVBORw0KGgo=" }, + { type: "inputImage", imageUrl: "https://example.test/remote.png" }, + { type: "inputImage", imageUrl: "data:image/png;base64," }, + { type: "inputAudio", audioUrl: "data:audio/wav;base64,AAAA" }, + ]); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 61cc4b41a502a..a4c6c61365f2e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -490,11 +490,19 @@ function codexMcpAppResourceUri(item: CodexDynamicToolItem): string | undefined return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined; } -function codexDynamicToolOutput( +const CODEX_IMAGE_DATA_URL = /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/i; + +export function codexDynamicToolOutput( item: Extract, ): unknown | undefined { if (item.contentItems !== null && item.contentItems !== undefined) { - return item.contentItems; + return item.contentItems.map((content) => { + if (content.type !== "inputImage") return content; + const match = CODEX_IMAGE_DATA_URL.exec(content.imageUrl); + return match?.[1] && match[2] + ? { type: "image", mimeType: match[1].toLowerCase(), data: match[2] } + : content; + }); } return item.success === false ? { success: false } : undefined; } @@ -578,10 +586,15 @@ function approvalDecisionToLegacyReviewDecision( function providerRequestKindFromPermissions( permissions: CodexSchema.PermissionsRequestApprovalParams["permissions"], ): ProviderRequestKind { - if ((permissions.fileSystem?.write?.length ?? 0) > 0) { + // Codex leaves the legacy `read`/`write` lists out when a path is a glob or + // special path, and is replacing them with `entries`. + const requests = (access: "read" | "write") => + (permissions.fileSystem?.[access]?.length ?? 0) > 0 || + permissions.fileSystem?.entries?.some((entry) => entry.access === access); + if (requests("write")) { return "file-change"; } - if ((permissions.fileSystem?.read?.length ?? 0) > 0) { + if (requests("read")) { return "file-read"; } return "command"; diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index ff19634917f31..17fe7224773d5 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -719,4 +719,190 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { assert.deepEqual([...projected.readyCheckpointOrdinals].toSorted(), [0, 1]); }), ); + + it.effect("completes a side chat run with a missing checkpoint and never touches git", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const sideChatThreadId = ThreadId.make("thread:checkpoint-capture-side-chat"); + const sideChatRunId = RunId.make("run:checkpoint-capture-side-chat"); + const sideChatScopeId = CheckpointScopeId.make("scope:checkpoint-capture-side-chat"); + const sideChatRootNodeId = NodeId.make("node:checkpoint-capture-side-chat-root"); + const sideChatProviderThreadId = ProviderThreadId.make( + "provider-thread:checkpoint-capture-side-chat", + ); + const event = { threadId: sideChatThreadId, occurredAt: now }; + const events: ReadonlyArray = [ + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:thread"), + type: "thread.created", + payload: { + createdBy: "user", + creationSource: "web", + id: sideChatThreadId, + projectId, + title: "Side chat", + providerInstanceId, + modelSelection, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: "/repo", + activeProviderThreadId: null, + lineage: { + parentThreadId: threadId, + relationshipToParent: "fork", + rootThreadId: threadId, + sideChat: true, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:provider-thread"), + type: "provider-thread.updated", + driver, + providerInstanceId, + payload: { + id: sideChatProviderThreadId, + driver, + providerInstanceId, + providerSessionId: null, + appThreadId: sideChatThreadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:run"), + type: "run.updated", + runId: sideChatRunId, + payload: { + id: sideChatRunId, + threadId: sideChatThreadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: sideChatProviderThreadId, + userMessageId: MessageId.make("message:checkpoint-capture-side-chat"), + rootNodeId: sideChatRootNodeId, + activeAttemptId: null, + status: "waiting", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:node"), + type: "node.updated", + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + payload: { + id: sideChatRootNodeId, + threadId: sideChatThreadId, + runId: sideChatRunId, + parentNodeId: null, + rootNodeId: sideChatRootNodeId, + kind: "root_turn", + status: "waiting", + countsForRun: true, + providerThreadId: sideChatProviderThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: sideChatScopeId, + startedAt: now, + completedAt: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:scope"), + type: "checkpoint-scope.created", + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + payload: { + id: sideChatScopeId, + threadId: sideChatThreadId, + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + parentScopeId: null, + providerThreadId: sideChatProviderThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/repo", + createdAt: now, + }, + }, + ]; + yield* Effect.forEach(events, (candidate) => projectionStore.apply(candidate)); + const gitUnavailable = () => Effect.die("side chats must not snapshot the shared worktree"); + const layerCapture = CheckpointCaptureService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + CheckpointService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + NodeCrypto.layer, + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: gitUnavailable, + captureCheckpoint: gitUnavailable, + hasCheckpointRef: gitUnavailable, + }), + ), + ), + ), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => + Effect.forEach(input.events, (committed) => projectionStore.apply(committed)).pipe( + Effect.as({ committed: true } as never), + Effect.orDie, + ), + }), + ), + ), + ); + + yield* CheckpointCaptureService.CheckpointCaptureServiceV2.use((service) => + service.execute({ + threadId: sideChatThreadId, + runId: sideChatRunId, + scopeId: sideChatScopeId, + }), + ).pipe(Effect.provide(layerCapture)); + + const { checkpoints, runs } = yield* projectionStore.getThreadRecords(sideChatThreadId, [ + "runs", + "checkpoints", + ]); + assert.equal(runs[0]?.status, "completed"); + const checkpoint = checkpoints.find((candidate) => candidate.id === runs[0]?.checkpointId); + assert.equal(checkpoint?.status, "missing"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 8924943fda29f..9d7d3cfd4ae2a 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -65,7 +65,7 @@ export const layer: Layer.Layer< readonly runId: RunId; readonly scopeId: CheckpointScopeId; }) { - const { run, rootNode, scope, providerThread, readyCheckpointOrdinals } = + const { run, rootNode, scope, providerThread, readyCheckpointOrdinals, sideChat } = yield* projections.getCheckpointCaptureContext(input.threadId, input); // Stopped runs remain terminal; their checkpoint is the next message's rollback point. const stopped = run?.status === "interrupted" || run?.status === "cancelled"; @@ -109,12 +109,14 @@ export const layer: Layer.Layer< : yield* checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: 0, + forceMissing: sideChat, }); const baselineCheckpoint = hasReadyCheckpoint(baselineOrdinalWithinScope) ? null : yield* checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: baselineOrdinalWithinScope, + forceMissing: sideChat, }); const checkpoint = yield* checkpoints.capture({ scope, @@ -123,6 +125,7 @@ export const layer: Layer.Layer< ordinalWithinScope: run.ordinal, appRunOrdinal: run.ordinal, capturedAt, + forceMissing: sideChat, }); // Omit delegatedCompletion to preserve cohort writes made after capture loaded the run. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index f7290faf9c9c3..145de122f387a 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -64,6 +64,7 @@ it.effect.each([ id: otherId, projectId, deletedAt: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: otherId }, worktreePath: owner === "ancestor" || owner === "conversation" ? parent @@ -202,3 +203,52 @@ it.effect("lets rollback recreate a worktree storage cleanup removed", () => assert.isTrue(isolated); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect.each([ + { sideChat: "answering questions", running: false, toolItems: 0, isolated: true }, + { sideChat: "running", running: true, toolItems: 0, isolated: false }, + { sideChat: "after running tools", running: false, toolItems: 1, isolated: false }, +] as const)( + "a side chat $sideChat shares the parent's worktree: $isolated", + ({ running, toolItems, isolated }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-v2-restore-side-chat-", + }); + const parentId = ThreadId.make("restore-side-chat-parent"); + const sideChat = { + id: ThreadId.make("restore-side-chat"), + projectId: ProjectId.make("restore-project"), + deletedAt: null, + worktreePath, + activeRunId: running ? "side-chat-run" : null, + lineage: { + parentThreadId: parentId, + relationshipToParent: "fork", + rootThreadId: parentId, + sideChat: true, + }, + }; + const result = yield* isCheckpointRestoreIsolated( + { id: parentId, worktreePath }, + { cwd: worktreePath }, + { + fileSystem, + path, + projections: { + getShellSnapshot: () => Effect.succeed({ threads: [sideChat], archivedThreads: [] }), + getThreadRecords: () => + Effect.succeed({ + turnItems: Array.from({ length: toolItems }, () => ({ type: "command_execution" })), + }), + getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] }), + getThreadProviderContext: () => Effect.succeed({ providerSessions: [] }), + } as never, + projects: {} as never, + }, + ); + assert.equal(result, isolated); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 268549d7bf7fe..b7dc08018a896 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -7,7 +7,7 @@ import type { ProjectionStoreV2 } from "./ProjectionStore.ts"; import type * as ProjectStore from "./ProjectStore.ts"; export const SHARED_WORKSPACE_RESTORE_MESSAGE = - "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead."; + "File restore requires an isolated worktree. This workspace may contain changes from another thread or a side chat. Rewind the conversation without restoring files instead, or delete side chats you no longer need."; // Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared. export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( @@ -48,6 +48,21 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi const checkedPaths = new Set(); for (const otherThread of [...shell.threads, ...shell.archivedThreads]) { if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue; + // An idle side chat that only answered questions never touched the shared worktree. + if ( + otherThread.lineage.sideChat === true && + otherThread.activeRunId === null && + otherThread.worktreePath === worktreePath + ) { + const { turnItems } = yield* projections.getThreadRecords( + otherThread.id, + ["turnItems"], + { + turnItemTypes: ["command_execution", "file_change", "dynamic_tool"], + }, + ); + if (turnItems.length === 0) continue; + } const other = yield* projections.getCheckpointContext(otherThread.id); const providerContext = yield* projections.getThreadProviderContext(otherThread.id); const paths = [ diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 2747fcefac79a..5990456e64fdf 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -118,6 +118,8 @@ export interface CheckpointServiceV2Shape { readonly materializeBaselineCheckpoint: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly ordinalWithinScope: number; + /** Records a missing checkpoint without touching git, for side chats sharing a parent's worktree. */ + readonly forceMissing?: boolean; }) => Effect.Effect; readonly capture: (input: { readonly scope: OrchestrationV2CheckpointScope; @@ -126,6 +128,7 @@ export interface CheckpointServiceV2Shape { readonly ordinalWithinScope: number; readonly appRunOrdinal: number | null; readonly capturedAt: DateTime.Utc; + readonly forceMissing?: boolean; }) => Effect.Effect; readonly restore: (input: { readonly scope: OrchestrationV2CheckpointScope; @@ -299,7 +302,8 @@ export const layer: Layer.Layer< scopeId: input.scope.id, ordinalWithinScope: input.ordinalWithinScope, }); - const checkpointable = yield* isGitCheckpointable(input.scope.cwd); + const checkpointable = + input.forceMissing !== true && (yield* isGitCheckpointable(input.scope.cwd)); const available = checkpointable ? yield* checkpointStore .hasCheckpointRef({ @@ -364,7 +368,7 @@ export const layer: Layer.Layer< ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1), }); - if (!(yield* isGitCheckpointable(input.scope.cwd))) { + if (input.forceMissing === true || !(yield* isGitCheckpointable(input.scope.cwd))) { return makeCheckpoint({ id: checkpointId, scope: input.scope, diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index aa63eec35a21b..f7fd1c7897936 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -260,9 +260,31 @@ layer("CommandPolicyV2", (it) => { hasStrongNativeSource: true, sourceRunStatus: "completed", fromSpecificTurn: true, + sourceBusy: false, }); assert.equal(result, "portable_context"); }), ); + + it.effect("forks a busy source through portable context", () => + Effect.gen(function* () { + const policy = yield* CommandPolicy.CommandPolicyV2; + const decide = (sourceBusy: boolean) => + policy.decideForkExecution({ + commandId, + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: CodexProviderCapabilitiesV2, + sameProvider: true, + hasStrongNativeSource: true, + sourceRunStatus: "completed", + fromSpecificTurn: true, + sourceBusy, + }); + + assert.equal(yield* decide(false), "native_fork"); + assert.equal(yield* decide(true), "portable_context"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 869851790177a..02f329e536feb 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -115,7 +115,8 @@ export function resolveMessageDispatchIntent( : mode; } -function isActiveRun(run: OrchestrationV2Run): boolean { +/** A run that still owns the thread's provider turn. */ +export function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || run.status === "starting" || @@ -210,6 +211,8 @@ export interface CommandPolicyV2Shape { readonly hasStrongNativeSource: boolean; readonly sourceRunStatus: OrchestrationV2Run["status"]; readonly fromSpecificTurn: boolean; + /** Later source runs are still active; providers cannot fork a thread mid-turn. */ + readonly sourceBusy: boolean; }, ) => Effect.Effect; readonly ensureRollback: ( @@ -373,6 +376,7 @@ const decideForkExecution: CommandPolicyV2Shape["decideForkExecution"] = (input) (input.sourceRunStatus === "completed" || input.sourceRunStatus === "waiting") && input.sameProvider && input.hasStrongNativeSource && + !input.sourceBusy && input.capabilities.threads.canForkThread && (!input.fromSpecificTurn || input.capabilities.threads.canForkFromTurn) && input.capabilities.identity.nativeThreadIds === "strong"; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index ff2ca1fbec441..adff6f65d8e75 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -12,6 +12,10 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; +/** The provider prompt for a turn that carries inline context ahead of the user's text. */ +export const withInlineContext = (context: string, userText: string): string => + context === "" ? userText : `${context}\n\nUser message:\n${userText}`; + /** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index aa13f23b9508b..d9005e6b6402e 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -1,11 +1,13 @@ import { assert, it } from "@effect/vitest"; import { MessageId, + ProjectId, ProviderInstanceId, ProviderThreadId, RunId, ThreadId, TurnItemId, + type OrchestrationV2AppThread, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -14,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProjectionStoreThreadNotFoundError } from "./ProjectionStore.ts"; const layerTest = ContextHandoffService.layer.pipe(Layer.provide(IdAllocator.layer)); @@ -156,3 +159,91 @@ it.layer(layerTest)("ContextHandoffService legacy import", (it) => { }), ); }); + +it("caps the side chat context block, keeping the newest parent items", () => { + const parentThreadId = ThreadId.make("thread:side-chat-parent"); + const digest = Array.from({ length: 60 }, (_, index) => + ContextHandoffService.summarizeDeltaItem( + importedItem({ + role: index % 2 === 0 ? "user" : "assistant", + id: `parent-${index}`, + text: `item-${index} ${"x".repeat(300)}`, + ordinal: index, + }), + )!, + ); + const context = ContextHandoffService.makeSideChatContext({ parentThreadId, digest }); + + assert.include(context, "- Earlier items truncated."); + assert.notInclude(context, "item-0 "); + assert.include(context, "- Assistant: item-59 "); + assert.isAtMost(context.length, 8_000); + assert.isTrue( + context.endsWith( + `Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`, + ), + ); + + const framingOnly = ContextHandoffService.makeSideChatContext({ parentThreadId, digest: [] }); + assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread.")); + assert.notInclude(framingOnly, "Main thread activity"); +}); + +it("covers the run range of a very long delta", () => { + const runs = [ + { id: RunId.make("run:long-delta-early"), ordinal: 2 }, + { id: RunId.make("run:long-delta-late"), ordinal: 5 }, + ]; + const items = Array.from({ length: 150_000 }, (_, index) => ({ + runId: runs[index % 2]!.id, + })); + + assert.deepEqual( + ContextHandoffService.visibleDeltaRunOrdinals({ runs }, [...items, { runId: null }]), + { from: 2, to: 5 }, + ); +}); + +it.effect("inherited history stops at a missing fork ancestor", () => + Effect.gen(function* () { + const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + const threadId = ThreadId.make("thread:fork-of-missing"); + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:fork-of-missing"), + title: "Fork of a missing thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: { + type: "run", + threadId: ThreadId.make("thread:missing-ancestor"), + runId: RunId.make("run:missing-ancestor"), + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + }; + const items = yield* ContextHandoffService.readInheritedHandoffItems( + { + getThreadRecords: (missing) => + Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: missing })), + }, + thread, + ); + assert.deepEqual(items, []); + }), +); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 1a1767b4df59f..67a474d963bb0 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -1,7 +1,9 @@ import { + type OrchestrationV2AppThread, OrchestrationV2ContextHandoff, type OrchestrationV2TurnItem, type OrchestrationV2Run, + type OrchestrationV2ThreadProjection, ProviderInstanceId, ProviderThreadId, RunId, @@ -22,6 +24,8 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { createOrchestrationV2TurnItemVisibility } from "@t3tools/shared/orchestrationV2Timeline"; +import type { ProjectionStoreV2Shape } from "./ProjectionStore.ts"; export class ContextHandoffPrepareError extends Schema.TaggedError()( "ContextHandoffPrepareError", @@ -80,6 +84,19 @@ export interface ContextHandoffServiceV2Shape { readonly items: ReadonlyArray; readonly createdAt: DateTime.Utc; }) => Effect.Effect; + /** Parent activity after the fork point, prepended to a side chat's first provider turn. */ + readonly prepareSideChatContext: (input: { + readonly threadId: ThreadId; + readonly parentThreadId: ThreadId; + readonly targetRunId: RunId; + readonly transferId: NonNullable; + readonly toProviderThreadId: ProviderThreadId; + readonly fromProviderInstanceId: ProviderInstanceId; + readonly toProviderInstanceId: ProviderInstanceId; + readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly parentItems: ReadonlyArray; + readonly createdAt: DateTime.Utc; + }) => Effect.Effect; } export class ContextHandoffServiceV2 extends Context.Service< @@ -95,7 +112,97 @@ function compactText(text: string, maxLength = 240): string { return `${compacted.slice(0, maxLength - 3)}...`; } -function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { +export function visibleDeltaRunOrdinals( + projection: { readonly runs: ReadonlyArray> }, + items: ReadonlyArray>, +): OrchestrationV2ContextHandoff["coveredRunOrdinals"] { + const ordinalByRunId = new Map(projection.runs.map((run) => [run.id, run.ordinal])); + let from = Infinity; + let to = -Infinity; + for (const item of items) { + const ordinal = item.runId === null ? undefined : ordinalByRunId.get(item.runId); + if (ordinal === undefined) continue; + from = Math.min(from, ordinal); + to = Math.max(to, ordinal); + } + return from === Infinity ? { from: 1, to: 1 } : { from, to }; +} + +/** Drops rolled-back runs and cancelled queued messages, which are not part of the conversation. */ +export function visibleConversationItems( + projection: Pick, + items: ReadonlyArray, +): ReadonlyArray { + const visible = createOrchestrationV2TurnItemVisibility({ + runs: projection.runs, + attempts: projection.attempts, + items, + }); + return items.filter(visible); +} + +type ForkHistorySource = Pick; + +/** Visible rows of `source`'s own runs through `throughRun`, plus its v1 imported rows. */ +const readOwnHistoryItems = Effect.fn("orchestrationV2.contextHandoff.readOwnHistoryItems")( + function* ( + projectionStore: Pick, + source: ForkHistorySource, + throughRun: Pick, + ) { + const { turnItems } = yield* projectionStore.getThreadRecords(source.thread.id, ["turnItems"], { + turnItemRunIds: [ + ...source.runs.filter((run) => run.ordinal <= throughRun.ordinal).map((run) => run.id), + ...(source.thread.historyOrigin === "v1_import" ? [null] : []), + ], + }); + return visibleConversationItems(source, turnItems); + }, +); + +/** History `thread` shows from its fork ancestors, oldest first. Side chats inherit no rows. */ +export const readInheritedHandoffItems = Effect.fn( + "orchestrationV2.contextHandoff.readInheritedHandoffItems", +)(function* ( + projectionStore: Pick, + thread: OrchestrationV2AppThread, +) { + const segments: Array> = []; + const seen = new Set([thread.id]); + let current = thread; + while ( + current.forkedFrom?.type === "run" && + current.lineage.sideChat !== true && + !seen.has(current.forkedFrom.threadId) + ) { + const { threadId, runId } = current.forkedFrom; + seen.add(threadId); + const source = yield* projectionStore + .getThreadRecords(threadId, ["runs", "attempts"]) + .pipe(Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) })); + const forkRun = source?.runs.find((run) => run.id === runId); + if (source === null || forkRun === undefined) break; + segments.unshift(yield* readOwnHistoryItems(projectionStore, source, forkRun)); + current = source.thread; + } + return segments.flat(); +}); + +/** What a portable fork from `throughRun` of `source` carries: inherited rows, then its own. */ +export const readForkHistoryItems = Effect.fn( + "orchestrationV2.contextHandoff.readForkHistoryItems", +)(function* ( + projectionStore: Pick, + source: ForkHistorySource, + throughRun: Pick, +) { + return [ + ...(yield* readInheritedHandoffItems(projectionStore, source.thread)), + ...(yield* readOwnHistoryItems(projectionStore, source, throughRun)), + ]; +}); + +export function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { switch (item.type) { case "user_message": return `- User: ${compactText(item.text)}`; @@ -135,6 +242,41 @@ function makeForkDeltaSummary(input: { ].join("\n"); } +const SIDE_CHAT_CONTEXT_MAX_CHARS = 8_000; + +/** `digest` holds `summarizeDeltaItem` lines for parent items after the fork point, oldest first. */ +export function makeSideChatContext(input: { + readonly parentThreadId: ThreadId; + readonly digest: ReadonlyArray; +}): string { + const framing = + "You are in a side chat beside the main thread. The main thread may still be running in the same worktree. Answer the user's questions; do not continue or redo the main thread's task."; + const header = "Main thread activity since this side chat's starting point:"; + const truncated = "- Earlier items truncated."; + const footer = `Use t3_thread_read on thread ${input.parentThreadId} to see the main thread's live state.`; + const kept: Array = []; + // The cap covers the whole block, so the fixed lines are reserved before any digest line. + let remaining = + SIDE_CHAT_CONTEXT_MAX_CHARS - [framing, "", header, truncated, "", footer].join("\n").length; + for (const line of input.digest.toReversed()) { + if (line.length + 1 > remaining) break; + kept.unshift(line); + remaining -= line.length + 1; + } + return [ + framing, + ...(kept.length === 0 + ? [] + : ["", header, ...(kept.length < input.digest.length ? [truncated] : []), ...kept]), + "", + footer, + ].join("\n"); +} + +/** Side chat context is prepended to the provider prompt, never replayed as budgeted history. */ +export const isSideChatContextHandoff = (handoff: OrchestrationV2ContextHandoff): boolean => + handoff.strategy === "manual_context" && handoff.transferId != null; + function makeLegacyImportSummary(items: ReadonlyArray): string { const sections = items.flatMap((item) => { switch (item.type) { @@ -412,10 +554,56 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi } satisfies OrchestrationV2ContextHandoff; }); + const prepareSideChatContext = Effect.fn( + "orchestrationV2.contextHandoff.prepareSideChatContext", + )(function* (input: Parameters[0]) { + const handoffId = yield* idAllocator.allocate + .contextHandoff({ + threadId: input.threadId, + fromProviderInstanceId: input.fromProviderInstanceId, + toProviderInstanceId: input.toProviderInstanceId, + }) + .pipe( + Effect.mapError( + (cause) => + new ContextHandoffPrepareError({ + threadId: input.threadId, + targetRunId: input.targetRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.toProviderThreadId, + cause, + }), + ), + ); + return { + id: handoffId, + transferId: input.transferId, + threadId: input.threadId, + targetRunId: input.targetRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.toProviderThreadId, + coveredRunOrdinals: input.coveredRunOrdinals, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: makeSideChatContext({ + parentThreadId: input.parentThreadId, + digest: input.parentItems.flatMap((item) => { + const line = summarizeDeltaItem(item); + return line === null ? [] : [line]; + }), + }), + createdByProviderInstanceId: null, + createdAt: input.createdAt, + updatedAt: input.createdAt, + } satisfies OrchestrationV2ContextHandoff; + }); + return ContextHandoffServiceV2.of({ prepareLegacyImport, prepareForkDelta, prepareProviderHandoff, + prepareSideChatContext, }); }, ); diff --git a/apps/server/src/orchestration-v2/IdleCompaction.test.ts b/apps/server/src/orchestration-v2/IdleCompaction.test.ts index bfa36cd2e5131..65360492bd2df 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.test.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.test.ts @@ -148,7 +148,7 @@ function records( } describe("idleCompactionWindow", () => { - it("skips small contexts, unknown expiry, other providers, switched threads, and compactions", () => { + it("skips small contexts, unknown expiry, other providers, switched threads, compactions, and side chats", () => { const cache = { cacheExpiresInMs: 60 * MINUTE_MS }; expect(idleCompactionWindow(records({ ...cache, usedTokens: 199_999 }), NOW_MS)).toBeNull(); expect(idleCompactionWindow(records(), NOW_MS)).toBeNull(); @@ -166,6 +166,13 @@ describe("idleCompactionWindow", () => { const archived = records(cache); archived.thread = { ...archived.thread, archivedAt: at() }; expect(idleCompactionWindow(archived, NOW_MS)).toBeNull(); + const sideChat = records(cache); + expect(idleCompactionWindow(sideChat, NOW_MS)).not.toBeNull(); + sideChat.thread = { + ...sideChat.thread, + lineage: { ...sideChat.thread.lineage, relationshipToParent: "fork", sideChat: true }, + }; + expect(idleCompactionWindow(sideChat, NOW_MS)).toBeNull(); }); }); diff --git a/apps/server/src/orchestration-v2/IdleCompaction.ts b/apps/server/src/orchestration-v2/IdleCompaction.ts index b142dd4efb19d..a89894045c298 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.ts @@ -1,4 +1,4 @@ -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { isListedThreadLineage, type OrchestrationV2ThreadProjection } from "@t3tools/contracts"; import { COMPACTION_SUGGESTION_MIN_TOKENS } from "@t3tools/shared/claudeCompaction"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as DateTime from "effect/DateTime"; @@ -50,7 +50,7 @@ export function idleCompactionWindow( if (thread.settledAt !== null || thread.settledOverride === "settled") return null; if (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) return null; - if (thread.lineage.relationshipToParent === "subagent") return null; + if (!isListedThreadLineage(thread.lineage)) return null; const latestRun = runs.reduce<(typeof runs)[number] | null>( (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest), null, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 25d2579a1ff2c..f9485db4edc3e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -59,6 +59,7 @@ import { latestProviderTurnForAttempt, parseGoalCommand, orchestrationV2RunWorkStartedAt, + orchestrationV2WorktreePending, ProviderInstanceId, ProviderInteractionMode, resolveProviderInstanceEnabled, @@ -99,11 +100,18 @@ import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2, goalCommandStopsActiveRun, + isActiveRun, isNativeMaintenanceCommand, resolveMessageDispatchIntent, } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; -import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; +import { + ContextHandoffServiceV2, + readForkHistoryItems, + readInheritedHandoffItems, + visibleConversationItems, + visibleDeltaRunOrdinals, +} from "./ContextHandoffService.ts"; import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { cancelledBeforeStart, @@ -636,15 +644,6 @@ function mayStartSubagentFollowup( return command.createdBy === "user" || command.creationSource === "mcp"; } -function isBlockingRun(run: OrchestrationV2Run): boolean { - return ( - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting" - ); -} - /** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ function isLiveRun(run: OrchestrationV2Run): boolean { return run.status === "preparing" || run.status === "starting" || run.status === "running"; @@ -918,26 +917,6 @@ function latestContextTransfer( }, undefined); } -function visibleDeltaRunOrdinals( - projection: Pick, - items: ReadonlyArray, -): OrchestrationV2ContextHandoff["coveredRunOrdinals"] { - const ordinals = items.flatMap((item) => { - if (item.runId === null) { - return []; - } - const run = projection.runs.find((candidate) => candidate.id === item.runId); - return run === undefined ? [] : [run.ordinal]; - }); - if (ordinals.length === 0) { - return { from: 1, to: 1 }; - } - return { - from: Math.min(...ordinals), - to: Math.max(...ordinals), - }; -} - export function shouldPrepareLegacyImportHandoff(input: { readonly hasCompletedRun: boolean; readonly historyOrigin: OrchestrationV2AppThread["historyOrigin"]; @@ -1198,6 +1177,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); + const readInheritedItems = (thread: OrchestrationV2AppThread) => + readInheritedHandoffItems(projectionStore, thread).pipe( + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId: thread.id, cause })), + ); + + /** Includes tombstones, which still reserve their id. */ + const findThread = (threadId: ThreadId) => + projectionStore.getThread(threadId).pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + ); + + /** Side chats live inside their parent, so deleting the parent deletes them too. */ + const liveSideChatIds = Effect.fn("Orchestrator.liveSideChatIds")( + function* (threadId: ThreadId) { + const { contextTransfers } = yield* projectionStore + .getThreadRecords(threadId, ["contextTransfers"]) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed({ contextTransfers: [] }), + }), + ); + const sideChatIds: Array = []; + for (const transfer of contextTransfers) { + if (transfer.type !== "fork" || transfer.sourceThreadId !== threadId) continue; + const child = yield* projectionStore + .getThread(transfer.targetThreadId) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + ); + if (child?.lineage.sideChat === true && child.deletedAt === null) { + sideChatIds.push(child.id); + } + } + return sideChatIds; + }, + (effect, threadId) => + effect.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + ); + const getProjectionWithPendingEvents = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ Effect.fnUntraced(function* ( @@ -1526,7 +1545,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || - projection.runs.some(isBlockingRun) + projection.runs.some(isActiveRun) ) { return; } @@ -4245,26 +4264,55 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is unlocked; validate the modes copied here, not an earlier read. + // Only side chat forks lock the source; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); + if (command.sideChat === true && sourceProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.sourceThreadId} was deleted.`, + }); + } + // The fork would copy no worktree path and run in the project checkout. + if ( + command.sideChat === true && + orchestrationV2WorktreePending(sourceProjection.thread.worktreePath, sourceProjection.runs) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.sourceThreadId} has no worktree yet. Start the side chat once its worktree is set up.`, + }); + } + if ((yield* findThread(command.targetThreadId)) !== null) { + return yield* new OrchestratorThreadAlreadyExistsError({ + commandId: command.commandId, + threadId: command.targetThreadId, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); - if (sourceRun === null) { + // A side chat may open during the parent's first turn; it starts from a digest instead. + if ( + sourceRun === null && + !(command.sideChat === true && command.sourcePoint.type === "latest_stable") + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause: `No stable source run was found for fork source ${command.sourcePoint.type}.`, }); } - if (!isForkableSourceRunStatus(sourceRun.status)) { + if (sourceRun !== null && !isForkableSourceRunStatus(sourceRun.status)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause: forkableSourceRunStatusError(sourceRun), }); } - const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); + const sourceProviderThread = + sourceRun === null ? undefined : providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); const transferId = yield* mapDispatchError(command)( @@ -4279,11 +4327,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio sourceProjection, sourceRun, sourceProviderThread, - sourceTokenUsage: providerTurnForRun(sourceProjection, sourceRun)?.tokenUsage, - canonicalSourcePoint: contextSourcePointForRun(sourceProjection, sourceRun), + sourceTokenUsage: + sourceRun === null + ? undefined + : providerTurnForRun(sourceProjection, sourceRun)?.tokenUsage, + canonicalSourcePoint: + sourceRun === null + ? { threadId: sourceProjection.thread.id } + : contextSourcePointForRun(sourceProjection, sourceRun), transferId, targetThreadId: command.targetThreadId, ...(command.title === undefined ? {} : { title: command.title }), + ...(command.sideChat ? { sideChat: true } : {}), createdBy: command.createdBy, creationSource: command.creationSource, createdAt: now, @@ -4300,7 +4355,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* emitEvent({ type: "context-transfer.created", threadId: command.targetThreadId, - providerInstanceId: sourceRun.providerInstanceId, + providerInstanceId: transfer.sourceProviderInstanceId ?? targetThread.providerInstanceId, occurredAt: now, payload: transfer, }); @@ -4360,6 +4415,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.sourceThreadId} is not a fork of ${command.targetThreadId}.`, }); } + // Queued runs cannot consume merge-back context. Refuse under the target's + // command lock before a pending transfer can block its ordinary messages. + if (targetProjection.runs.some((run) => isActiveRun(run) || run.status === "queued")) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Finish or stop the target thread's active work and clear its queue before merging back.", + }); + } + const otherPendingMergeBack = targetProjection.contextTransfers.some( + (transfer) => + transfer.type === "merge_back" && + transfer.status === "pending" && + transfer.targetThreadId === command.targetThreadId && + transfer.sourceThreadId !== command.sourceThreadId, + ); + if (otherPendingMergeBack) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.targetThreadId} already has pending context from another side chat or fork. Send a message there first.`, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); if (sourceRun === null) { @@ -4391,17 +4470,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - // Queued runs cannot consume merge-back context. Refuse under the target's - // command lock before a pending transfer can block its ordinary messages. - if (targetProjection.runs.some((run) => isBlockingRun(run) || run.status === "queued")) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: - "Finish or stop the target thread's active work and clear its queue before merging back.", - }); - } - const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); @@ -5651,7 +5719,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const activeProviderThread = projection.providerThreads.find( (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); - const activeRun = projection.runs.find(isBlockingRun); + const activeRun = projection.runs.find(isActiveRun); // A message queued during worktree setup can arrive after that setup failed // without a worktree. It waits, held, like the messages queued before the // failure, instead of starting in the project checkout. @@ -6431,8 +6499,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio sourceProjection.attempts.find((candidate) => candidate.id === sourceRun.activeAttemptId) ?.providerTurnId ?? undefined); + const sideChatFork = + pendingForkTransfer !== undefined && projection.thread.lineage.sideChat === true; + // Opened during the parent's first turn: the digest is the whole context. + const sideChatWithoutSource = + sideChatFork && pendingForkTransfer.sourcePoint.runId === undefined; if (pendingForkTransfer !== undefined) { - if (sourceRun === null || sourceProviderThread === undefined) { + if (!sideChatWithoutSource && (sourceRun === null || sourceProviderThread === undefined)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -6497,6 +6570,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + const sourceRunsAfterFork = + sourceProjection === null + ? [] + : sourceProjection.runs.filter( + (candidate) => sourceRun === null || candidate.ordinal > sourceRun.ordinal, + ); const forkExecution = pendingForkTransfer === undefined || sourceRun === null ? null @@ -6511,6 +6590,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio hasStrongNativeSource: sourceProviderThread?.nativeThreadRef?.strength === "strong", sourceRunStatus: sourceRun.status, fromSpecificTurn: sourceRun !== null, + sourceBusy: sourceRunsAfterFork.some(isActiveRun), }), ); const canResolveForkNatively = forkExecution === "native_fork"; @@ -6567,12 +6647,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const portableForkItems = !requiresPortableFork || sourceProjection === null || sourceRun === null ? [] - : yield* readHandoffItems(sourceProjection.thread.id, [ - ...sourceProjection.runs - .filter((run) => run.ordinal <= sourceRun.ordinal) - .map((run) => run.id), - ...(sourceProjection.thread.historyOrigin === "v1_import" ? [null] : []), - ]); + : yield* readForkHistoryItems(projectionStore, sourceProjection, sourceRun).pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ threadId: sourceProjection.thread.id, cause }), + ), + ); const portableForkHandoff = !requiresPortableFork || pendingForkTransfer === undefined || @@ -6605,6 +6685,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // Without a source run the digest covers the parent's whole history: inherited fork rows + // first, so the cap truncates them first, then imported and run items. + const sideChatParentRunIds = [ + // Queued messages have not reached the main thread yet. + ...sourceRunsAfterFork.filter((run) => run.status !== "queued").map((run) => run.id), + ...(sourceRun === null && sourceProjection?.thread.historyOrigin === "v1_import" + ? [null] + : []), + ]; + const sideChatParentItems = + !sideChatFork || sourceProjection === null + ? [] + : [ + ...(sideChatWithoutSource ? yield* readInheritedItems(sourceProjection.thread) : []), + ...(sideChatParentRunIds.length === 0 + ? [] + : visibleConversationItems( + sourceProjection, + yield* readHandoffItems(sourceProjection.thread.id, sideChatParentRunIds), + )), + ]; + // The framing note goes in even when the parent has no activity after the fork point. + const sideChatContextHandoff = + !sideChatFork || pendingForkTransfer === undefined || sourceProjection === null + ? null + : yield* contextHandoffService + .prepareSideChatContext({ + threadId: command.threadId, + parentThreadId: sourceProjection.thread.id, + targetRunId: runId, + transferId: pendingForkTransfer.id, + toProviderThreadId: ensuredProviderThread.id, + fromProviderInstanceId: + pendingForkTransfer.sourceProviderInstanceId ?? modelSelection.instanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: visibleDeltaRunOrdinals(sourceProjection, sideChatParentItems), + parentItems: sideChatParentItems, + createdAt: now, + }) + .pipe(mapDispatchError(command)); const requiresFullProviderSwitchContext = isProviderSwitch && pendingMergeBackTransfer !== undefined; const targetLastCompletedRun = @@ -6721,9 +6841,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio lastRunOrdinal: ordinal, handoffIds: [ ...ensuredProviderThread.handoffIds, - ...[portableForkHandoff, providerSwitchHandoff, legacyImportRecoveryHandoff].flatMap( - (handoff) => (handoff === null ? [] : [handoff.id]), - ), + ...[ + portableForkHandoff, + sideChatContextHandoff, + providerSwitchHandoff, + legacyImportRecoveryHandoff, + ].flatMap((handoff) => (handoff === null ? [] : [handoff.id])), ], updatedAt: now, }; @@ -6789,11 +6912,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const mergeBackDeltaItems = mergeBackSourceProjection === null || mergeBackSourceRun === null ? [] - : yield* readHandoffItems( - mergeBackSourceProjection.thread.id, - mergeBackSourceProjection.runs - .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) - .map((run) => run.id), + : visibleConversationItems( + mergeBackSourceProjection, + yield* readHandoffItems( + mergeBackSourceProjection.thread.id, + mergeBackSourceProjection.runs + .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) + .map((run) => run.id), + ), ); const mergeBackHandoff = pendingMergeBackTransfer === undefined || @@ -7026,12 +7152,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio strategy: "native_fork", providerThreadRef: providerThread.nativeThreadRef, }; + const portableForkContext = + portableForkHandoff ?? (sideChatWithoutSource ? sideChatContextHandoff : null); const portableForkResolution: OrchestrationV2ContextTransferResolution | null = - pendingForkTransfer === undefined || portableForkHandoff === null + pendingForkTransfer === undefined || portableForkContext === null ? null : { strategy: "portable_context", - contextHandoffId: portableForkHandoff.id, + contextHandoffId: portableForkContext.id, }; const mergeBackResolution: OrchestrationV2ContextTransferResolution | null = pendingMergeBackTransfer === undefined || mergeBackHandoff === null @@ -7094,6 +7222,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: portableForkHandoff, }); } + if (sideChatContextHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: sideChatContextHandoff, + }); + } if (legacyImportRecoveryHandoff !== null) { yield* emitEvent({ type: "context-handoff.updated", @@ -7353,7 +7491,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun)) { + if (parentRun === undefined || !isActiveRun(parentRun)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -7657,7 +7795,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (original === undefined || original.result === null) return; const owner = - parent.runs.filter(isBlockingRun).toSorted((a, b) => b.ordinal - a.ordinal)[0] ?? + parent.runs.filter(isActiveRun).toSorted((a, b) => b.ordinal - a.ordinal)[0] ?? latestExecutedRun(parent.runs); if (owner === null || owner.rootNodeId === null) return; const now = message.createdAt; @@ -7784,7 +7922,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun) || parentRun.rootNodeId === null) { + if (parentRun === undefined || !isActiveRun(parentRun) || parentRun.rootNodeId === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -8506,7 +8644,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } if ( existing === undefined && - (!isBlockingRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) + (!isActiveRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -9915,7 +10053,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Run ${command.runId} has no failed workspace preparation to retry.`, }); } - if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { + if (projection.runs.some((run) => run.id !== state.run.id && isActiveRun(run))) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -11415,7 +11553,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio continue; const idle = idleChildren.get(task.childThreadId) ?? - (!child.runs.some((run) => run.status === "queued" || isBlockingRun(run)) && + (!child.runs.some((run) => run.status === "queued" || isActiveRun(run)) && !child.providerThreads.some( (thread) => thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0, @@ -12688,6 +12826,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchOnce = Effect.fn("orchestrationV2.dispatch.once")(function* ( command: OrchestrationV2ServerCommand, + sideChatIds: ReadonlyArray, ): Effect.fn.Return< { readonly events: ReadonlyArray; @@ -12754,31 +12893,64 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio break; } case "thread.delete": { - const projection = yield* projectionStore - .getThreadRecords(command.threadId, [ - "runs", - "attempts", - "nodes", - "runtimeRequests", - "subagents", - "providerSessions", - ]) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), + const now = yield* DateTime.now; + const planDeletion = Effect.fn("Orchestrator.planDeletion")(function* ( + deletion: typeof command, + ) { + const projection = yield* projectionStore + .getThreadRecords(deletion.threadId, [ + "runs", + "attempts", + "nodes", + "runtimeRequests", + "subagents", + "providerSessions", + ]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: deletion.threadId, cause }), + ), + ); + return yield* mapDispatchError(command)( + planThreadDeletion({ + command: deletion, + projection, + attachmentIds: yield* projectionStore + .getThreadAttachmentIds(deletion.threadId) + .pipe(mapDispatchError(command)), + now, + idAllocator, + }), ); - return yield* mapDispatchError(command)( - planThreadDeletion({ - command, - projection, - attachmentIds: yield* projectionStore - .getThreadAttachmentIds(command.threadId) - .pipe(mapDispatchError(command)), - now: yield* DateTime.now, - idAllocator, - }), - ); + }); + // Checked under the thread lock: a message sent since the client looked keeps the thread. + if (command.onlyIfUnused === true) { + const usage = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "messages"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (usage.runs.length > 0 || usage.messages.length > 0) { + return { events: [], effects: [] }; + } + } + // The dispatch wrapper holds each side chat's lock, so none can commit after this plan. + const plans = [ + yield* planDeletion(command), + ...(yield* Effect.forEach(sideChatIds, (threadId) => + planDeletion({ + ...command, + commandId: CommandId.make(`${command.commandId}:side-chat:${threadId}`), + threadId, + }), + )), + ]; + return { + events: plans.flatMap((plan) => plan.events), + effects: plans.flatMap((plan) => plan.effects), + }; } case "thread.archive": case "thread.unarchive": @@ -12942,7 +13114,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio selectedMessage === undefined || selectedMessage.delegatedCompletion !== undefined || selectedMessage.notification !== undefined || - projection.runs.some(isBlockingRun) + projection.runs.some(isActiveRun) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -13066,8 +13238,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; }); + /** `sideChatIds` are the live side chats a `thread.delete` cascades to, read under their locks. */ const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, + sideChatIds: ReadonlyArray = [], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -13143,7 +13317,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); } - const plan = yield* dispatchOnce(command).pipe( + const plan = yield* dispatchOnce(command, sideChatIds).pipe( Effect.filterOrFail( (planned) => planned.events.length > 0 || @@ -13152,7 +13326,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.stop" || command.type === "subagent.stop" || // Closing a share again, or after it ended, is not an error. - command.type === "thread.guest-share.close", + command.type === "thread.guest-share.close" || + (command.type === "thread.delete" && command.onlyIfUnused === true), (_planned) => new OrchestratorDispatchError({ commandId: command.commandId, @@ -13295,6 +13470,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandThreadId(command), dispatchWithReceiptEffect(command), ); + // A parent's lock comes before its side chats' locks, so a delete cascade and a side chat + // fork or command never interleave. A fork from a side chat or onto an existing thread would + // invert that order; dispatch rejects both, so they skip the source lock. + if ( + command.type === "thread.fork" && + command.sideChat === true && + command.sourceThreadId !== command.targetThreadId + ) { + const source = yield* findThread(command.sourceThreadId); + const target = yield* findThread(command.targetThreadId); + return yield* source === null || source.lineage.sideChat === true || target !== null + ? dispatch + : threadDispatch.withLock(command.sourceThreadId, dispatch); + } + if (command.type === "thread.delete") + return yield* threadDispatch.withLock( + command.threadId, + liveSideChatIds(command.threadId).pipe( + Effect.flatMap((sideChatIds) => + sideChatIds.reduce( + (locked, sideChatId) => threadDispatch.withLock(sideChatId, locked), + dispatchWithReceiptEffect(command, sideChatIds), + ), + ), + ), + ); // Answering a message-mode question can also start a follow-up. if ( command.type !== "runtime-request.respond" && diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 608c8b0ddad48..96249ece4c106 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -23,6 +23,8 @@ const row = (overrides: Partial = {}): ProjectRow => ({ faviconPath: null, projectIcon: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, @@ -77,6 +79,38 @@ describe("planProjectCommand", () => { ); }); + it("keeps the first pin and archive time, and clears both on undo", () => { + const earlier = "2025-12-01T00:00:00.000Z"; + const update = (project: ProjectRow, change: { pinned?: boolean; archived?: boolean }) => + payloadOf( + plan( + { + type: "project.meta.update", + commandId: CommandId.make("cmd-organize"), + projectId, + ...change, + }, + { project }, + ), + ); + assert.deepInclude(update(row(), { pinned: true, archived: true }), { + pinnedAt: "2026-01-01T00:00:00.000Z", + archivedAt: "2026-01-01T00:00:00.000Z", + }); + const organized = row({ pinnedAt: earlier, archivedAt: earlier }); + assert.deepInclude(update(organized, { pinned: true, archived: true }), { + pinnedAt: earlier, + archivedAt: earlier, + }); + assert.deepInclude(update(organized, { pinned: false, archived: false }), { + pinnedAt: null, + archivedAt: null, + }); + const untouched = update(organized, {}); + assert.notProperty(untouched, "pinnedAt"); + assert.notProperty(untouched, "archivedAt"); + }); + it("rejects a workspace root held by another active project", () => { const owner = row({ projectId: ProjectId.make("project-existing"), diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index 674a04783e347..d4c1abb3ceda2 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -37,6 +37,8 @@ export interface ProjectMetaUpdateCommand { readonly faviconPath?: string | null; readonly projectIcon?: ProjectIconOverride | null; readonly scripts?: ReadonlyArray; + readonly pinned?: boolean; + readonly archived?: boolean; } export interface ProjectDeleteCommand { @@ -208,6 +210,13 @@ export function planProjectCommand(input: { ...(command.faviconPath === undefined ? {} : { faviconPath: command.faviconPath }), ...(command.projectIcon === undefined ? {} : { projectIcon: command.projectIcon }), ...(command.scripts === undefined ? {} : { scripts: command.scripts }), + // Repeating a pin or archive keeps the original time. + ...(command.pinned === undefined + ? {} + : { pinnedAt: command.pinned ? (project.pinnedAt ?? occurredAt) : null }), + ...(command.archived === undefined + ? {} + : { archivedAt: command.archived ? (project.archivedAt ?? occurredAt) : null }), updatedAt: occurredAt, }, }); diff --git a/apps/server/src/orchestration-v2/ProjectStore.test.ts b/apps/server/src/orchestration-v2/ProjectStore.test.ts index 77cc0fda60e25..3eeabb6f89a83 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.test.ts @@ -51,5 +51,50 @@ it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemor ); }), ); + + it.effect("keeps pin and archive times across later updates", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + const projectId = ProjectId.make("project-organized"); + const at = "2026-03-24T00:00:00.000Z"; + const event = { aggregateKind: "project", aggregateId: projectId, occurredAt: at } as const; + const meta = { commandId: null, causationEventId: null, correlationId: null, metadata: {} }; + yield* projects.apply({ + ...event, + ...meta, + sequence: 1, + eventId: EventId.make("event-organized-created"), + type: "project.created", + payload: { + projectId, + title: "Organized", + workspaceRoot: "/tmp/project-organized", + defaultModelSelection: null, + scripts: [], + createdAt: at, + updatedAt: at, + }, + }); + yield* projects.apply({ + ...event, + ...meta, + sequence: 2, + eventId: EventId.make("event-organized-pinned"), + type: "project.meta-updated", + payload: { projectId, pinnedAt: at, archivedAt: at, updatedAt: at }, + }); + yield* projects.apply({ + ...event, + ...meta, + sequence: 3, + eventId: EventId.make("event-organized-renamed"), + type: "project.meta-updated", + payload: { projectId, title: "Renamed", updatedAt: at }, + }); + const project = Option.getOrNull(yield* projects.get(projectId)); + assert.strictEqual(project?.pinnedAt, at); + assert.strictEqual(project?.archivedAt, at); + }), + ); }, ); diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index 4a81ee2b62500..2be0a765c784f 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -39,6 +39,8 @@ export const ProjectRow = Schema.Struct({ faviconPath: Schema.NullOr(Schema.String), projectIcon: Schema.NullOr(StoredProjectIcon), scripts: Schema.Array(ProjectScript), + pinnedAt: Schema.NullOr(IsoDateTime), + archivedAt: Schema.NullOr(IsoDateTime), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -65,6 +67,8 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { faviconPath: row.faviconPath, projectIcon: row.projectIcon, scripts: row.scripts, + pinnedAt: row.pinnedAt, + archivedAt: row.archivedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -120,6 +124,8 @@ export const make = Effect.gen(function* () { favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", + pinned_at AS "pinnedAt", + archived_at AS "archivedAt", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -150,6 +156,8 @@ export const make = Effect.gen(function* () { favicon_path, project_icon_json, scripts_json, + pinned_at, + archived_at, created_at, updated_at, deleted_at @@ -164,6 +172,8 @@ export const make = Effect.gen(function* () { ${encoded.faviconPath}, ${encoded.projectIcon}, ${encoded.scripts}, + ${encoded.pinnedAt}, + ${encoded.archivedAt}, ${encoded.createdAt}, ${encoded.updatedAt}, ${encoded.deletedAt} @@ -178,6 +188,8 @@ export const make = Effect.gen(function* () { favicon_path = excluded.favicon_path, project_icon_json = excluded.project_icon_json, scripts_json = excluded.scripts_json, + pinned_at = excluded.pinned_at, + archived_at = excluded.archived_at, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -226,6 +238,8 @@ export const make = Effect.gen(function* () { faviconPath: payload.faviconPath ?? null, projectIcon: payload.projectIcon ?? null, scripts: payload.scripts, + pinnedAt: null, + archivedAt: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -256,6 +270,8 @@ export const make = Effect.gen(function* () { ...(payload.faviconPath === undefined ? {} : { faviconPath: payload.faviconPath }), ...(payload.projectIcon === undefined ? {} : { projectIcon: payload.projectIcon }), ...(payload.scripts === undefined ? {} : { scripts: payload.scripts }), + ...(payload.pinnedAt === undefined ? {} : { pinnedAt: payload.pinnedAt }), + ...(payload.archivedAt === undefined ? {} : { archivedAt: payload.archivedAt }), updatedAt: payload.updatedAt, }).pipe(mapError("apply")); }, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 510a89739b7df..069511b8e4dd8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5086,13 +5086,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); - it.effect("counts imported runless history inherited by fork shells", () => + it.effect("counts history inherited by fork shells, but not by side chats", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; const projectId = ProjectId.make("project:projection-imported-fork-shell"); const sourceThreadId = ThreadId.make("thread:projection-imported-fork-shell:source"); const targetThreadId = ThreadId.make("thread:projection-imported-fork-shell:target"); + const sideChatThreadId = ThreadId.make("thread:projection-imported-fork-shell:side-chat"); const sourceRunId = RunId.make("run:projection-imported-fork-shell:source"); const rootNodeId = NodeId.make("node:projection-imported-fork-shell:source"); @@ -5130,43 +5131,49 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }, }); - yield* projectionStore.apply({ - id: EventId.make("event:projection-imported-fork-shell:target-thread"), - type: "thread.created", - threadId: targetThreadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: targetThreadId, - projectId, - title: "Imported fork target", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - parentThreadId: sourceThreadId, - relationshipToParent: "fork", - rootThreadId: sourceThreadId, - }, - forkedFrom: { - type: "run", - threadId: sourceThreadId, - runId: sourceRunId, + for (const [threadId, sideChat] of [ + [targetThreadId, false], + [sideChatThreadId, true], + ] as const) { + yield* projectionStore.apply({ + id: EventId.make(`event:projection-imported-fork-shell:created:${threadId}`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Imported fork target", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: sourceThreadId, + relationshipToParent: "fork", + rootThreadId: sourceThreadId, + sideChat, + }, + forkedFrom: { + type: "run", + threadId: sourceThreadId, + runId: sourceRunId, + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, }, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); + }); + } yield* projectionStore.apply({ id: EventId.make("event:projection-imported-fork-shell:source-run"), type: "run.created", @@ -5235,6 +5242,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.equal(targetShell.itemCount, 0); assert.equal(targetShell.visibleItemCount, 4); assert.equal(targetProjection.visibleTurnItems.length, 4); + + const sideChatShell = shell.threads.find((thread) => thread.id === sideChatThreadId); + assert.equal(sideChatShell?.visibleItemCount, 0); + const sideChatProjection = yield* projectionStore.getThreadProjection(sideChatThreadId); + assert.deepEqual(sideChatProjection.visibleTurnItems, []); + const sideChatTimeline = yield* projectionStore.getTimelinePage(sideChatThreadId, { + limit: 20, + view: "activity", + }); + assert.equal(sideChatTimeline.totalItems, 0); }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 5846512f3f782..770f33a3e6342 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -256,6 +256,8 @@ export interface ProjectionCheckpointCaptureContext { readonly scope: OrchestrationV2CheckpointScope | undefined; readonly providerThread: OrchestrationV2ProviderThread | undefined; readonly readyCheckpointOrdinals: ReadonlyArray; + /** Side chats share their parent's worktree and never snapshot it. */ + readonly sideChat: boolean; } const RollbackTargetAttempts = Schema.Array( @@ -1335,14 +1337,21 @@ function visibleTurnItemsThroughRun(input: { return [...inheritedPrefix, ...localPrefix]; } +// Side chats show only their own rows; the fork transfer carries the parent's context to the model. +function inheritedRunFork(thread: Pick) { + return thread.forkedFrom?.type === "run" && thread.lineage.sideChat !== true + ? thread.forkedFrom + : null; +} + function buildVisibleTurnItems(input: { readonly projection: OrchestrationV2ThreadProjection; readonly sourceProjection: OrchestrationV2ThreadProjection | null; readonly inheritedRowCount?: number; }): Array { - const forkedFrom = input.projection.thread.forkedFrom; + const forkedFrom = inheritedRunFork(input.projection.thread); if ( - forkedFrom?.type !== "run" || + forkedFrom === null || (input.sourceProjection === null && input.inheritedRowCount === undefined) ) { return localVisibleTurnItems(input.projection); @@ -1721,8 +1730,8 @@ function visibleItemCountForShell(input: { return 0; } - const forkedFrom = state.thread.forkedFrom; - if (forkedFrom?.type !== "run") { + const forkedFrom = inheritedRunFork(state.thread); + if (forkedFrom === null) { return state.itemCount; } @@ -1736,9 +1745,8 @@ function visibleItemCountForShell(input: { return state.itemCount; } - const sourceForkedFrom = sourceState.thread.forkedFrom; const inheritedPrefixCount = - sourceForkedFrom?.type === "run" + inheritedRunFork(sourceState.thread) !== null ? visibleItemCountForShell({ threadId: sourceState.thread.id, statesByThreadId: input.statesByThreadId, @@ -3627,8 +3635,8 @@ export const layer: Layer.Layer = ? ["runs", "attempts", "turnItems"] : undefined, ); - const forkedFrom = projection.thread.forkedFrom; - if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) { + const forkedFrom = inheritedRunFork(projection.thread); + if (forkedFrom === null || seenThreadIds.has(forkedFrom.threadId)) { return withLocalVisibleTurnItems(projection); } @@ -4304,6 +4312,8 @@ export const layer: Layer.Layer = message_id IN (SELECT json_extract(payload_json, '$.userMessageId') FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${runId}) OR (role = 'user' AND lower(trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace})) = '/compact') + -- Earlier /goal turns defer context too; parseGoalCommand narrows this prefix match. + OR (role = 'user' AND trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace}) GLOB '/goal*') ) ORDER BY created_at ASC, message_id ASC `.pipe(Effect.flatMap(decodeRows(decodeMessagePayload, threadId))); const conversation = yield* sql<{ present: number }>` @@ -5063,7 +5073,12 @@ export const layer: Layer.Layer = sql .withTransaction( Effect.gen(function* () { - yield* requireThread(threadId); + const threadRows = yield* sql<{ readonly side_chat: number | null }>` + SELECT json_extract(payload_json, '$.lineage.sideChat') AS side_chat + FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} LIMIT 1`; + if (threadRows[0] === undefined) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } const runRows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${target.runId}`; @@ -5100,6 +5115,7 @@ export const layer: Layer.Layer = ? undefined : yield* decodeProviderThreadPayload(providerRows[0].payload_json), readyCheckpointOrdinals: readyRows.map(({ ordinal }) => ordinal), + sideChat: threadRows[0].side_chat === 1, }; }), ) @@ -5503,8 +5519,8 @@ export const layer: Layer.Layer = items, }); const visible = local.filter((row) => isVisible(row.item)); - const fork = records.thread.forkedFrom; - if (fork?.type !== "run" || seen.has(fork.threadId)) return { records, local, visible }; + const fork = inheritedRunFork(records.thread); + if (fork === null || seen.has(fork.threadId)) return { records, local, visible }; const source = yield* readTimelineIndex( fork.threadId, new Set([...seen, threadId]), @@ -5864,6 +5880,7 @@ export const layer: Layer.Layer = t.payload_json, CASE WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 THEN json_extract(t.payload_json, '$.forkedFrom.threadId') ELSE NULL END AS forked_from_run_source_thread_id, @@ -6748,6 +6765,7 @@ export const layer: Layer.Layer = readonly relationship_to_parent: string | null; readonly parent_thread_id: string | null; readonly root_thread_id: string; + readonly side_chat: number | null; readonly settled_override: string | null; readonly settled_at: string | null; readonly pinned_at: string | null; @@ -6760,6 +6778,7 @@ export const layer: Layer.Layer = json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.lineage.sideChat') AS side_chat, json_extract(t.payload_json, '$.settledOverride') AS settled_override, json_extract(t.payload_json, '$.settledAt') AS settled_at, json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, @@ -6814,6 +6833,7 @@ export const layer: Layer.Layer = ? row.relationship_to_parent : null, rootThreadId: ThreadId.make(row.root_thread_id), + ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), }, settledOverride: row.settled_override === "settled" || row.settled_override === "active" @@ -6849,6 +6869,7 @@ export const layer: Layer.Layer = WHERE t.deleted_at IS NULL AND json_extract(t.payload_json, '$.archivedAt') IS NULL AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL${ projectIds === undefined ? sql`` : sql` AND t.project_id IN ${sql.in(projectIds)}` diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d9287644be82a..05fc1b8545bb2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,6 +3,8 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, + ContextHandoffId, + ContextTransferId, GitCommandError, MessageId, NodeId, @@ -40,6 +42,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { withInlineContext } from "./ContextHandoffDelivery.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -96,6 +99,7 @@ function startWithMissingWorktree( projectId: ProjectId.make("project_provider_turn_start_projection_failure"), branch: "feature/restore", worktreePath, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, }, runs: [ { @@ -286,6 +290,16 @@ function makeLocalCommandHarness(input: { readonly interruptFailure?: boolean; readonly interruptCurrentRun?: boolean; }; + /** + * A side chat whose context targets this run, an earlier run that failed or was cancelled before + * the provider, or an earlier run that delivered it. `nativeForkFrom` plans a native fork of the + * parent. `switchedProvider` puts the earlier run and its context on another provider thread. + */ + readonly sideChat?: { + readonly contextRun: "current" | "failed" | "cancelled" | "delivered"; + readonly nativeForkFrom?: "idle" | "busy"; + readonly switchedProvider?: boolean; + }; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -354,6 +368,7 @@ function makeLocalCommandHarness(input: { projectId: ProjectId.make("project-native-account-command"), branch: input.worktreePath === undefined ? null : "feature/restore", worktreePath: input.worktreePath ?? null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2ThreadProjection["thread"], runs: [ ...(input.previousNativeSession @@ -458,6 +473,139 @@ function makeLocalCommandHarness(input: { checkpoints: [], updatedAt: now, }; + const parentThreadId = ThreadId.make("side-chat-parent"); + const parentRunId = RunId.make("side-chat-parent-run"); + const parentProviderThread = { + ...providerThread, + id: ProviderThreadId.make("side-chat-parent-provider-thread"), + appThreadId: parentThreadId, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "side-chat-parent-native", + strength: "strong" as const, + }, + }; + const parentRecords = { + ...projection, + thread: { ...projection.thread, id: parentThreadId }, + runs: [ + { + ...run, + id: parentRunId, + threadId: parentThreadId, + ordinal: 1, + providerThreadId: parentProviderThread.id, + activeAttemptId: null, + status: "completed" as const, + completedAt: now, + }, + ...(input.sideChat?.nativeForkFrom === "busy" + ? [ + { + ...run, + id: RunId.make("side-chat-parent-busy-run"), + threadId: parentThreadId, + ordinal: 2, + providerThreadId: parentProviderThread.id, + status: "running" as const, + }, + ] + : []), + ], + providerThreads: [parentProviderThread], + attempts: [], + providerTurns: [], + turnItems: [], + }; + if (input.sideChat !== undefined) { + const earlierRunId = RunId.make("side-chat-earlier-run"); + projection = { + ...projection, + thread: { + ...projection.thread, + runtimeMode: "full-access", + lineage: { + parentThreadId, + relationshipToParent: "fork", + rootThreadId: parentThreadId, + sideChat: true, + }, + }, + runs: + input.sideChat.contextRun === "current" + ? projection.runs + : [ + { + ...run, + id: earlierRunId, + ordinal: 1, + userMessageId: MessageId.make("side-chat-earlier-message"), + activeAttemptId: RunAttemptId.make("side-chat-earlier-attempt"), + ...(input.sideChat.switchedProvider + ? { providerThreadId: oldProviderThreadId } + : {}), + status: + input.sideChat.contextRun === "delivered" + ? "completed" + : input.sideChat.contextRun, + completedAt: now, + }, + run, + ], + contextTransfers: + input.sideChat.nativeForkFrom === undefined + ? [] + : [ + { + id: ContextTransferId.make("side-chat-fork"), + type: "fork", + sourceThreadId: parentThreadId, + targetThreadId: threadId, + sourcePoint: { threadId: parentThreadId, runId: parentRunId }, + basePoint: null, + sourceProviderInstanceId: newInstanceId, + targetProviderInstanceId: newInstanceId, + targetRunId: runId, + status: "pending", + resolution: null, + createdBy: "user", + error: null, + createdAt: now, + updatedAt: now, + consumedAt: null, + }, + ], + contextHandoffs: [ + { + id: ContextHandoffId.make("side-chat-context"), + transferId: ContextTransferId.make("side-chat-fork"), + threadId, + targetRunId: input.sideChat.contextRun === "current" ? runId : earlierRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.sideChat.switchedProvider + ? oldProviderThreadId + : providerThreadId, + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: "SIDE_CHAT_CONTEXT", + createdByProviderInstanceId: null, + createdAt: now, + updatedAt: now, + ...(input.sideChat.contextRun === "delivered" + ? { + delivery: { + nativeThreadId: "side-chat-native", + status: "inline" as const, + itemIds: [], + }, + } + : {}), + }, + ], + }; + } if ("historyReadFailureAfterFallback" in input) { const nativeThreadRef = { driver: providerThread.driver, @@ -577,55 +725,78 @@ function makeLocalCommandHarness(input: { ), ensureThread: () => Effect.succeed(providerThread), }; + const providerSession = { + id: providerSessionId, + driver: providerThread.driver, + providerInstanceId: newInstanceId, + status: "ready", + cwd: "/tmp/native-account-command", + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const startTurn = vi.fn((_turn: ProviderAdapter.ProviderAdapterV2TurnInput) => Effect.void); + const sideChatThread = { + ...providerThread, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "side-chat-native", + strength: "strong" as const, + }, + }; + const forkThread = vi.fn(() => Effect.succeed(sideChatThread)); + const sideChatSession = { + driver: providerThread.driver, + providerSession, + ensureThread: () => Effect.succeed(sideChatThread), + resumeThread: () => Effect.succeed(sideChatThread), + forkThread, + startTurn, + }; const open = vi.fn(() => - input.interruptOpen === true - ? Effect.interrupt - : "historyReadFailureAfterFallback" in input - ? Effect.succeed(resumeFallbackSession as never) - : "ensureThreadFailure" in input - ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) - : "openFailure" in input - ? Effect.sync(() => { - if (input.interruptRunBeforeOpenFailure === true) interruptRun(); - }).pipe( - Effect.andThen( - Effect.fail( - new ProviderSessionManager.ProviderSessionOpenError({ - instanceId: newInstanceId, - providerSessionId, - cause: input.openFailure, - }), + input.sideChat !== undefined + ? Effect.succeed(sideChatSession as never) + : input.interruptOpen === true + ? Effect.interrupt + : "historyReadFailureAfterFallback" in input + ? Effect.succeed(resumeFallbackSession as never) + : "ensureThreadFailure" in input + ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) + : "openFailure" in input + ? Effect.sync(() => { + if (input.interruptRunBeforeOpenFailure === true) interruptRun(); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderSessionManager.ProviderSessionOpenError({ + instanceId: newInstanceId, + providerSessionId, + cause: input.openFailure, + }), + ), ), - ), - ) - : input.failReadsAfterRunning === true || input.previousTurn !== undefined - ? Effect.succeed({ - driver: providerThread.driver, - providerSession: { - id: providerSessionId, + ) + : input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.succeed({ driver: providerThread.driver, - providerInstanceId: newInstanceId, - status: "ready", - cwd: "/tmp/native-account-command", - model: null, - capabilities: CodexProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }, - ensureThread: () => Effect.succeed(providerThread), - resumeThread: () => Effect.succeed(projection.providerThreads[0]), - } as never) - : Effect.die("A local command must not open a native session."), + providerSession, + ensureThread: () => Effect.succeed(providerThread), + resumeThread: () => Effect.succeed(projection.providerThreads[0]), + } as never) + : Effect.die("A local command must not open a native session."), ); const startRootRun = vi.fn< (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect - >(() => - input.failReadsAfterRunning === true || input.previousTurn !== undefined - ? Effect.suspend(() => - providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, - ) - : Effect.die("A local command must not start a native turn."), + >((runInput) => + input.sideChat !== undefined + ? runInput.session.startTurn(runInput as never).pipe(Effect.orDie) + : input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.suspend(() => + providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, + ) + : Effect.die("A local command must not start a native turn."), ); const failReadIfRunning = Effect.suspend(() => input.failReadsAfterRunning === true && @@ -672,13 +843,16 @@ function makeLocalCommandHarness(input: { ); const resolveRuntimePolicy = vi.fn( ({ thread }: Parameters[0]) => - Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never), + Effect.succeed({ + cwd: thread.worktreePath ?? "/tmp/native-account-command", + runtimeMode: thread.runtimeMode, + } as never), ); const createWorktree = vi.fn(() => Effect.succeed({} as never), ); - const getThreadRecords = vi.fn(() => - Effect.succeed(projection), + const getThreadRecords = vi.fn((id) => + Effect.succeed(id === parentThreadId ? parentRecords : projection), ); const getTurnStartContext = vi.fn(() => Effect.succeed({ @@ -694,9 +868,30 @@ function makeLocalCommandHarness(input: { Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({ - prepareProviderHandoff: () => Effect.die("history read must fail first"), + prepareProviderHandoff: (handoff) => + input.sideChat?.nativeForkFrom === undefined + ? Effect.die("history read must fail first") + : Effect.succeed({ + ...handoff, + id: ContextHandoffId.make("side-chat-fork-context"), + createdByProviderInstanceId: handoff.toProviderInstanceId, + status: "ready", + summaryMessageId: null, + summaryText: "PARENT_HISTORY", + updatedAt: handoff.createdAt, + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent, + write: ({ events: incoming }) => + Effect.sync(() => { + for (const event of incoming) { + events.push(event); + projection = ProjectionStore.applyToProjection(projection, event); + } + return []; + }), }), - Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, layerWorktreeLocation(input.worktreesDir), input.fileSystem === undefined @@ -728,12 +923,14 @@ function makeLocalCommandHarness(input: { ), }), getTurnStartHistory: () => - Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId, - cause: input.historyReadFailureAfterFallback, - }), - ), + input.sideChat !== undefined + ? Effect.succeed([]) + : Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: input.historyReadFailureAfterFallback, + }), + ), }), Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open, @@ -767,6 +964,8 @@ function makeLocalCommandHarness(input: { open, writeIfRunCurrent, startRootRun, + startTurn, + forkThread, interruptTurn, tryHandlePromptCommand, events, @@ -1006,6 +1205,75 @@ effectIt.effect("recovers an abandoned failed turn before continuing its native }), ); +effectIt.effect.each([ + { contextRun: "current", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "failed", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "cancelled", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { + contextRun: "failed", + switchedProvider: true, + runtimeMode: "approval-required", + context: "SIDE_CHAT_CONTEXT", + }, + { contextRun: "delivered", runtimeMode: "full-access", context: "" }, +] as const)( + "sends side chat context inline once, for the first turn that reaches the provider: %j", + ({ runtimeMode, context, ...sideChat }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + sideChat, + }); + + yield* harness.start; + + // The turn that delivers the context is clamped; a later one follows the thread's mode. + expect(harness.open).toHaveBeenCalledWith( + expect.objectContaining({ runtimePolicy: expect.objectContaining({ runtimeMode }) }), + ); + expect(harness.startTurn.mock.calls.map(([turn]) => turn.message.text)).toEqual([ + withInlineContext(context, "What is the main thread doing?"), + ]); + expect(harness.projection().contextHandoffs).toMatchObject([ + { + id: "side-chat-context", + toProviderThreadId: "new-provider-thread", + delivery: { nativeThreadId: "side-chat-native", status: "inline" }, + }, + ]); + }), +); + +effectIt.effect.each([ + { nativeForkFrom: "idle", forks: true, transferStatus: "consumed", handoffIds: [] }, + { + nativeForkFrom: "busy", + forks: false, + transferStatus: "resolved_portable", + handoffIds: ["side-chat-fork-context"], + }, +] as const)( + "runs a planned native side chat fork when the parent is $nativeForkFrom", + ({ nativeForkFrom, forks, transferStatus, handoffIds }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + sideChat: { contextRun: "current", nativeForkFrom }, + }); + + yield* harness.start; + + expect(harness.forkThread).toHaveBeenCalledTimes(forks ? 1 : 0); + expect(harness.projection().contextTransfers).toMatchObject([ + { id: "side-chat-fork", status: transferStatus }, + ]); + expect(harness.startTurn.mock.calls[0]?.[0].message.text.includes("PARENT_HISTORY")).toBe( + !forks, + ); + expect(harness.projection().providerThreads[0]?.handoffIds).toEqual(handoffIds); + }), +); + effectIt.effect.each([ { runStatus: "failed", turnStatus: "completed" }, { runStatus: "completed", turnStatus: "completed" }, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 14c93a31ea047..402b2eaf3e04e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -34,6 +34,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { isActiveRun } from "./CommandPolicy.ts"; import { DEFAULT_HANDOFF_TOKEN_CAP, handoffTokenCapConfig, @@ -43,7 +44,7 @@ import { historicalMessage, latestNativeContextUsage, } from "@t3tools/provider-core/server/handoffBudget"; -import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; +import { deliverContextHandoffs, withInlineContext } from "./ContextHandoffDelivery.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -363,27 +364,40 @@ export const layer: Layer.Layer< .filter((transfer) => transfer.type === "subagent_result") .map((transfer) => transfer.id), ); + const isPendingForRun = (handoff: OrchestrationV2ContextHandoff, anyProviderThread = false) => + handoff.status === "ready" && + (handoff.targetRunId === run.id || + ((anyProviderThread || handoff.toProviderThreadId === run.providerThreadId) && + projection.runs.some( + (source) => + source.id === handoff.targetRunId && + (source.status === "failed" || + source.status === "interrupted" || + source.status === "cancelled" || + (source.status === "completed" && + handoff.delivery === undefined && + // Compaction and /goal turns defer history to the next turn. + projection.messages.some( + (message) => + message.id === source.userMessageId && + (isCompactionCommandMessage(message) || parseGoalCommand(message) !== null), + ))), + ))); + const isSideChatHandoff = (handoff: OrchestrationV2ContextHandoff) => + projection.thread.lineage.sideChat === true && + ContextHandoffService.isSideChatContextHandoff(handoff); const handoffs = projection.contextHandoffs.filter( (handoff) => + !isSideChatHandoff(handoff) && (handoff.transferId == null || !subagentResultTransfers.has(handoff.transferId)) && - handoff.status === "ready" && - (handoff.targetRunId === run.id || - (handoff.toProviderThreadId === run.providerThreadId && - projection.runs.some( - (source) => - source.id === handoff.targetRunId && - (source.status === "failed" || - source.status === "interrupted" || - (source.status === "completed" && - handoff.delivery === undefined && - // Compaction and /goal turns defer history to the next turn. - projection.messages.some( - (message) => - message.id === source.userMessageId && - (isCompactionCommandMessage(message) || - parseGoalCommand(message) !== null), - ))), - ))), + isPendingForRun(handoff), + ); + // Carried forward until a provider turn accepts it, also after a provider switch. + const sideChatHandoff = projection.contextHandoffs.find( + (handoff) => + isSideChatHandoff(handoff) && + handoff.delivery === undefined && + isPendingForRun(handoff, true), ); const nativeForkTransfer = projection.contextTransfers.find( (transfer) => @@ -675,8 +689,14 @@ export const layer: Layer.Layer< }); const { isCurrentAttemptInStatus } = runControls; + // A side chat shares its parent's worktree; the turn that gets its context asks before acting. const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ - thread: projection.thread, + thread: + projection.thread.lineage.sideChat === true && + (sideChatHandoff !== undefined || + !projection.runs.some((previous) => previous.ordinal < run.ordinal)) + ? { ...projection.thread, runtimeMode: "approval-required" } + : projection.thread, modelSelection: run.modelSelection, }); if (!(yield* isCurrentAttemptInStatus("starting"))) return; @@ -1072,6 +1092,7 @@ export const layer: Layer.Layer< }; let resumedRetryHandoff: ReadonlyArray | undefined; let resumedHandoffContext: ReturnType | undefined; + let portableForkFallback: OrchestrationV2ContextHandoff | undefined; const loadedProviderThread = yield* Effect.gen(function* () { if (nativeForkTransfer !== undefined) { const sourceProjection = yield* projectionStore.getThreadRecords( @@ -1098,18 +1119,91 @@ export const layer: Layer.Layer< cause: `Native fork transfer ${nativeForkTransfer.id} has no source provider execution.`, }); } - return yield* loadFromProvider( - session.forkThread({ - sourceProviderThread, - sourceProviderTurns: sourceProjection.providerTurns, - targetThreadId: projection.thread.id, + // Providers refuse to fork a busy thread, so a source that started a turn after + // dispatch planned this fork gets the portable context dispatch gives a busy source. + const sourceBusy = sourceProjection.runs.some( + (candidate) => candidate.ordinal > sourceRun.ordinal && isActiveRun(candidate), + ); + if (!sourceBusy) { + return yield* loadFromProvider( + session.forkThread({ + sourceProviderThread, + sourceProviderTurns: sourceProjection.providerTurns, + targetThreadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(sourceProviderTurn === undefined + ? {} + : { providerTurnId: sourceProviderTurn.id }), + }), + ); + } + const sourceItems = yield* ContextHandoffService.readForkHistoryItems( + projectionStore, + sourceProjection, + sourceRun, + ); + const createdAt = yield* DateTime.now; + const handoff = yield* contextHandoffService.prepareProviderHandoff({ + threadId: projection.thread.id, + targetRunId: run.id, + transferId: nativeForkTransfer.id, + fromProviderThreadIds: [sourceProviderThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: sourceRun.providerInstanceId, + toProviderInstanceId: run.providerInstanceId, + coveredRunOrdinals: ContextHandoffService.visibleDeltaRunOrdinals( + sourceProjection, + sourceItems, + ), + strategy: "full_thread_summary", + items: sourceItems, + runs: sourceProjection.runs, + createdAt, + }); + const loaded = yield* loadFromProvider( + session.ensureThread({ + threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, - ...(sourceProviderTurn === undefined - ? {} - : { providerTurnId: sourceProviderTurn.id }), + providerSessionId, + existingProviderThread: { ...providerThread, forkedFrom: null }, }), ); + if (loaded === undefined) return undefined; + portableForkFallback = handoff; + effectiveHandoffs = [handoff, ...effectiveHandoffs]; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: handoff, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-transfer.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: { + ...nativeForkTransfer, + targetProviderInstanceId: run.providerInstanceId, + status: "resolved_portable", + resolution: { strategy: "portable_context", contextHandoffId: handoff.id }, + error: null, + updatedAt: createdAt, + }, + }, + ], + }); + return loaded; } if (providerThread.nativeThreadRef === null) { // Pass the stored provider row for adoption; adapter-minted duplicates make activeProviderThreadId flap. @@ -1299,8 +1393,11 @@ export const layer: Layer.Layer< ownerNodeId: providerThread.ownerNodeId, firstRunOrdinal: providerThread.firstRunOrdinal ?? run.ordinal, lastRunOrdinal: run.ordinal, - handoffIds: providerThread.handoffIds, - forkedFrom: providerThread.forkedFrom, + handoffIds: + portableForkFallback === undefined + ? providerThread.handoffIds + : [...providerThread.handoffIds, portableForkFallback.id], + forkedFrom: portableForkFallback === undefined ? providerThread.forkedFrom : null, status: "active", createdAt: providerThread.createdAt, updatedAt: now, @@ -1345,7 +1442,9 @@ export const layer: Layer.Layer< occurredAt: now, payload: runningProviderThread, }, - ...(nativeForkTransfer === undefined || runningProviderThread.nativeThreadRef === null + ...(nativeForkTransfer === undefined || + portableForkFallback !== undefined || + runningProviderThread.nativeThreadRef === null ? [] : [ { @@ -1415,6 +1514,26 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); + const persistHandoff = Effect.fn("ProviderTurnStartService.persist")(function* ( + handoff: OrchestrationV2ContextHandoff, + ) { + const updatedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ + threadId: projection.thread.id, + }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: updatedAt, + payload: { ...handoff, updatedAt }, + }, + ], + }); + }); const startWithHandoffs = Effect.fn("ProviderTurnStartService.startWithHandoffs")( function* (turnInput: Parameters[0], compact = false) { const delivery = yield* deliverContextHandoffs({ @@ -1432,28 +1551,16 @@ export const layer: Layer.Layer< ...history, }), }), - persist: Effect.fn("ProviderTurnStartService.persist")(function* (handoff) { - const updatedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: yield* idAllocator.allocate.event({ - threadId: projection.thread.id, - }), - type: "context-handoff.updated", - threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: updatedAt, - payload: { ...handoff, updatedAt }, - }, - ], - }); - }), + persist: persistHandoff, }); if (!(yield* isCurrentAttemptInStatus("running"))) return; const start = compact ? session.compactThread! : session.startTurn; - const context = [delivery.context, restartNote] + // Like history, the side chat context waits for a turn that sends a prompt. + const sideChatContext = + sideChatHandoff === undefined || compact || goalCommand + ? "" + : sideChatHandoff.summaryText; + const context = [delivery.context, sideChatContext, restartNote] .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; @@ -1461,10 +1568,7 @@ export const layer: Layer.Layer< ...(noteContinuation ? promptedInput : turnInput), message: { ...turnInput.message, - text: - context === "" || goalCommand - ? userText - : `${context}\n\nUser message:\n${userText}`, + text: goalCommand ? userText : withInlineContext(context, userText), }, }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. @@ -1481,13 +1585,30 @@ export const layer: Layer.Layer< ), ); // After provider acceptance, failed delivery bookkeeping must not stop live ingestion. - yield* delivery.delivered.pipe( - Effect.catchCause(() => - Effect.logWarning("Failed to record accepted context handoff delivery", { - runId: run.id, - deliveryStatus: "pending", - }), - ), + const nativeThreadId = runningProviderThread.nativeThreadRef?.nativeId; + yield* Effect.forEach( + [ + delivery.delivered, + ...(sideChatContext === "" || sideChatHandoff === undefined || nativeThreadId == null + ? [] + : [ + persistHandoff({ + ...sideChatHandoff, + toProviderThreadId: runningProviderThread.id, + delivery: { nativeThreadId, status: "inline" as const, itemIds: [] }, + }), + ]), + ], + (record) => + record.pipe( + Effect.catchCause(() => + Effect.logWarning("Failed to record accepted context handoff delivery", { + runId: run.id, + deliveryStatus: "pending", + }), + ), + ), + { discard: true }, ); }, Effect.mapError((cause) => @@ -1505,6 +1626,7 @@ export const layer: Layer.Layer< const deliverySession = effectiveHandoffs.length === 0 && retryHandoff.length === 0 && + sideChatHandoff === undefined && restartNote === "" && !noteContinuation ? session diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 4d2bf3c95770f..c2c5632501687 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -34,7 +34,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; +import { + evaluatePullRequestWatch, + PULL_REQUEST_WATCH_NO_CHECKS_MS, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; /** Checks take minutes; faster sweeps waste the account's shared host quota. */ const SWEEP_MINUTES = 2; @@ -139,19 +143,28 @@ function planRead( if (last === undefined || group.targets.some((target) => !last.watches.has(watchKey(target)))) { return { detail: true, activity: true }; } + // Nothing on the host changes when the no-checks deadline passes, so it alone prompts a read. + const checksDue = group.targets.some( + ({ watch }) => + watch.checksAwaitedSince != null && + now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS, + ); if (fingerprint === null) { const read = last.inFlight || !last.remarksComplete || last.fingerprint !== group.fingerprint || now - last.at >= QUIET_REREAD_MS; - return { detail: read, activity: read }; + return { detail: read || checksDue, activity: read }; } const activity = last.remarks !== fingerprint.remarks || !last.remarksComplete || now - last.at >= FINGERPRINT_REREAD_MS; - return { detail: activity || last.status !== fingerprint.status || last.inFlight, activity }; + return { + detail: activity || checksDue || last.status !== fingerprint.status || last.inFlight, + activity, + }; } function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 82f74dff96e8e..4566cf4c97db8 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -917,21 +917,24 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - yield* checkpointService - .captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); + // Side chats share the parent's worktree; a snapshot would capture the parent's edits. + yield* ( + input.appThread.lineage.sideChat === true + ? Effect.void + : checkpointService.captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + ).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (_cause) => + Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), + ), + ); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 9ab00130f8d36..d410be8ddf1c3 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -96,6 +96,8 @@ const layerTest = RuntimePolicy.layerFromProjectStore.pipe( faviconPath: null, projectIcon: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-06-21T00:00:00.000Z", updatedAt: "2026-06-21T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index e308c74a00ff9..336f9b12f96bf 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -235,9 +235,24 @@ describe("omitOlderSettledShells", () => { omitOlderSettledShells({ threads, snapshotSequence: 7, now }); it("keeps the 25 newest settled threads and counts the rest per project", () => { - const result = omit([...newest, row("old", 30), row("b-old", 40), row("unsettled", null)]); + const sideChat = row("side-chat", 0, { + lineage: { + parentThreadId: ThreadId.make("new-0"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("new-0"), + sideChat: true, + }, + }); + const result = omit([ + ...newest, + sideChat, + row("old", 30), + row("b-old", 40), + row("unsettled", null), + ]); expect(result.threads.map((thread) => thread.id)).toEqual([ ...newest.map((thread) => thread.id), + "side-chat", "unsettled", ]); expect(result.olderSettled).toEqual({ @@ -262,7 +277,7 @@ describe("omitOlderSettledShells", () => { expect(result.olderSettled.counts).toEqual([]); }); - it("keeps older rows that are snoozed, live, or above a kept subagent, and still counts them", () => { + it("keeps older rows that are snoozed, live, or above a kept subagent or side chat, and still counts them", () => { const threads = [ ...newest, row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), @@ -280,6 +295,15 @@ describe("omitOlderSettledShells", () => { settledOverride: null, settledAt: null, }), + row("side-chat-parent", 30), + row("side-chat", null, { + lineage: { + parentThreadId: ThreadId.make("side-chat-parent"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("side-chat-parent"), + sideChat: true, + }, + }), ]; const result = omit(threads); expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ @@ -287,8 +311,10 @@ describe("omitOlderSettledShells", () => { "running", "parent", "child", + "side-chat-parent", + "side-chat", ]); - expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 4 }]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 5 }]); }); }); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 67d07d6684c08..0fa5251a7ef73 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -12,6 +12,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { + isListedThreadLineage, isLiveSubagentShell, isOlderSettledThreadShell, OrchestrationProjectShell as ProjectShellSchema, @@ -98,7 +99,11 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { if (row.lineage.relationshipToParent === "subagent") { subagentThreadIds.add(row.id); if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); - } else if (row.settledOverride === "settled" && row.settledAt !== null) { + } else if ( + isListedThreadLineage(row.lineage) && + row.settledOverride === "settled" && + row.settledAt !== null + ) { settledTimes.push(DateTime.toEpochMillis(row.settledAt)); } } @@ -121,10 +126,12 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { }; } -function subagentAncestorIds(threads: ReadonlyArray>) { +function hiddenChildAncestorIds( + threads: ReadonlyArray>, +) { const ancestorIds = new Set(); for (const thread of threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; + if (isListedThreadLineage(thread.lineage)) continue; if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); ancestorIds.add(thread.lineage.rootThreadId); } @@ -156,7 +163,7 @@ function omitOlderSettledShellsWith( } { const nowMs = DateTime.toEpochMillis(input.now); const beforeMs = facts.olderSettledBeforeMs ?? nowMs; - const ancestorIds = subagentAncestorIds(input.threads); + const ancestorIds = hiddenChildAncestorIds(input.threads); const threads = input.threads.filter( (thread) => !isOlderSettledThreadShell(thread, beforeMs) || @@ -207,7 +214,8 @@ export function activeShellRowsForSubscriber( /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while - * pinned, snoozed, live, or above a kept subagent, so walks over shell rows still find them. + * pinned, snoozed, live, or above a kept subagent or side chat, so walks over shell rows still find + * them. */ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; @@ -255,7 +263,10 @@ function shellSubscriberThreadIds( entry.lineage.relationshipToParent === "subagent" && (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), ); - const ancestorIds = subagentAncestorIds(subagents); + const ancestorIds = hiddenChildAncestorIds([ + ...subagents, + ...index.filter((entry) => entry.lineage.sideChat === true), + ]); const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); const beforeMs = facts.olderSettledBeforeMs; return index.flatMap((entry) => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 1015e44694c81..d4867a264ec9c 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1547,3 +1547,82 @@ it.effect("stops the running turn when a goal clear ends the goal driving it", ( }), ), ); + +it.effect("a side chat that opens with /goal gets its context on the next question", () => + Effect.scoped( + Effect.gen(function* () { + const { events, layer, started, startFirstTurn } = yield* goalHarness("side-chat-goal"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const parentId = yield* startFirstTurn; + const sideChatId = ThreadId.make("thread:side-chat-goal-child"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + const ask = (id: string, text: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId: sideChatId, + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const sideChatEvent = (matches: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter((event) => event.threadId === sideChatId && matches(event)), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + const running = yield* sideChatEvent( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + const settled = yield* sideChatEvent( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ); + yield* ask("goal", "/goal clear"); + yield* worker.drain(); + yield* Fiber.join(running); + const turn = (yield* orchestrator.getThreadProjection(sideChatId)).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: started[1]!.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* ask("question", "What is the main thread doing?"); + yield* worker.drain(); + + const [first, goal, question] = started.map((turn) => turn.message.text); + assert.deepEqual([first, goal], ["first", "/goal clear"]); + assert.include(question, "You are in a side chat beside the main thread."); + assert.include(question, "- User: first"); + assert.isTrue(question?.endsWith("What is the main thread doing?")); + }).pipe(Effect.provide(layer)); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 3350d31fb708d..71d40332f2d7a 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + CheckpointId, CommandId, EventId, MessageId, @@ -214,13 +215,15 @@ it.effect.each(forkCases)( const attemptId = RunAttemptId.make("interrupted-source-attempt"); const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); const rootNodeId = NodeId.make("interrupted-source-root"); + const grandparentThreadId = ThreadId.make("fork-boundary-grandparent"); + const grandparentRunId = RunId.make("fork-boundary-grandparent-run"); yield* orchestrator.dispatch({ type: "thread.create", - commandId: CommandId.make("create-source"), - threadId: sourceThreadId, + commandId: CommandId.make("create-grandparent"), + threadId: grandparentThreadId, projectId: ProjectId.make("fork-boundary-project"), - title: "Fork boundary source", + title: "Fork boundary grandparent", modelSelection, runtimeMode: "full-access", interactionMode: "default", @@ -229,6 +232,29 @@ it.effect.each(forkCases)( createdBy: "user", creationSource: "web", }); + yield* writeParentRun({ + threadId: grandparentThreadId, + runId: RunId.make("fork-boundary-grandparent-rolled-back-run"), + ordinal: 1, + status: "rolled_back", + text: "ROLLED_BACK_MARKER", + }); + yield* writeParentRun({ + threadId: grandparentThreadId, + runId: grandparentRunId, + ordinal: 2, + status: "completed", + text: "GRANDPARENT_MARKER", + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork-grandparent"), + sourceThreadId: grandparentThreadId, + targetThreadId: sourceThreadId, + sourcePoint: { type: "run", runId: grandparentRunId }, + createdBy: "user", + creationSource: "web", + }); yield* eventSink.write({ events: [ { @@ -246,8 +272,8 @@ it.effect.each(forkCases)( nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, nativeConversationHeadRef: null, status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 2, + firstRunOrdinal: 2, + lastRunOrdinal: 3, handoffIds: [], forkedFrom: null, createdAt: now, @@ -299,8 +325,15 @@ it.effect.each(forkCases)( ], }); } - for (const ordinal of [1, 2]) { - const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); + yield* writeParentRun({ + threadId: sourceThreadId, + runId: RunId.make("fork-boundary-source-rolled-back-run"), + ordinal: 1, + status: "rolled_back", + text: "SOURCE_ROLLED_BACK_MARKER", + }); + for (const ordinal of [2, 3]) { + const runId = ordinal === 2 ? sourceRunId : RunId.make("later-run"); const messageId = MessageId.make(`source-message-${ordinal}`); yield* eventSink.write({ events: [ @@ -319,8 +352,8 @@ it.effect.each(forkCases)( providerThreadId, userMessageId: messageId, rootNodeId: null, - activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, - status: ordinal === 1 ? status : "completed", + activeAttemptId: ordinal === 2 && status === "interrupted" ? attemptId : null, + status: ordinal === 2 ? status : "completed", queuePosition: null, requestedAt: now, startedAt: now, @@ -355,7 +388,7 @@ it.effect.each(forkCases)( creationSource: "web", inputIntent: "turn_start", messageId, - text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", + text: ordinal === 2 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", attachments: [], }, }, @@ -388,6 +421,9 @@ it.effect.each(forkCases)( assert.lengthOf(target.contextHandoffs, 1); const handoff = target.contextHandoffs[0]!; const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; + assert.include(`${handoff.summaryText}\n${history}`, "GRANDPARENT_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "SOURCE_ROLLED_BACK_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "ROLLED_BACK_MARKER"); assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); assert.isNull(target.providerThreads[0]?.forkedFrom); @@ -607,3 +643,561 @@ it.effect.each([ ); }, ); + +const writeParentRun = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + /** Preparing, failed, and interrupted runs asked for a new worktree. */ + readonly status: + | "preparing" + | "running" + | "queued" + | "completed" + | "rolled_back" + | "failed" + | "interrupted"; + readonly text: string; + /** A checkpointed run on a native provider thread, which `latest_stable` forks start from. */ + readonly stable?: boolean; +}) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { driver, instanceId, modelSelection } = forkCases[0]!; + const messageId = MessageId.make(`${input.runId}:message`); + const providerThreadId = input.stable + ? ProviderThreadId.make(`${input.runId}:provider-thread`) + : null; + yield* eventSink.write({ + events: [ + ...(providerThreadId === null + ? [] + : [ + { + id: EventId.make(`${input.runId}:provider-thread`), + type: "provider-thread.updated" as const, + threadId: input.threadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `${input.runId}:native`, + strength: "strong" as const, + }, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: input.ordinal, + lastRunOrdinal: input.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + ]), + { + id: EventId.make(`${input.runId}:created`), + type: "run.created", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: input.status, + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: input.status === "completed" ? now : null, + checkpointId: input.stable ? CheckpointId.make(`${input.runId}:checkpoint`) : null, + contextHandoffId: null, + ...(input.status === "preparing" || + input.status === "failed" || + input.status === "interrupted" + ? { workspacePreparation: { type: "worktree" as const, baseRef: "main" } } + : {}), + }, + }, + { + id: EventId.make(`${input.runId}:item`), + type: "turn-item.updated", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${input.runId}:item`), + threadId: input.threadId, + runId: input.runId, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal * 100, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId, + text: input.text, + attachments: [], + }, + }, + ], + }); + }); + +/** Marks `threadId` as imported from V1 and gives it one runless imported user message. */ +const writeImportedHistory = (threadId: ThreadId, text: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { thread } = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:imported`), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { ...thread, historyOrigin: "v1_import" }, + }, + { + id: EventId.make(`${threadId}:imported-item`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:imported-item`), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make(`${threadId}:imported-message`), + text, + attachments: [], + }, + }, + ], + }); + }); + +const createParent = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make("side-chat-project"), + title: "Side chat parent", + modelSelection: forkCases[0]!.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +const forkSideChat = (commandId: string, sourceThreadId: ThreadId, targetThreadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + return yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(commandId), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + }); + +const askSideChat = (threadId: ThreadId, commandId: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(commandId), + threadId, + messageId: MessageId.make(commandId), + text: "What is the main thread doing?", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + }); + +it.effect("a side chat of an idle parent still starts with the framing note", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("side-chat-idle-parent"); + const sideChatId = ThreadId.make("side-chat-idle-child"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-idle-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + + yield* forkSideChat("side-chat-idle-fork", parentId, sideChatId); + yield* askSideChat(sideChatId, "side-chat-idle-first-message"); + + const started = yield* orchestrator.getThreadProjection(sideChatId); + assert.isNotNull(started.providerThreads[0]?.forkedFrom); + assert.lengthOf(started.contextHandoffs, 1); + const [handoff] = started.contextHandoffs; + assert.isTrue( + handoff!.summaryText.startsWith("You are in a side chat beside the main thread."), + ); + assert.notInclude(handoff!.summaryText, "Main thread activity"); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("a side chat waits for the parent's new worktree, also after its setup ended", () => + Effect.gen(function* () { + for (const status of ["preparing", "failed", "interrupted"] as const) { + const parentId = ThreadId.make(`side-chat-worktree-parent-${status}`); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make(`side-chat-worktree-parent-run-${status}`), + ordinal: 1, + status, + text: "Parent work", + }); + + const rejected = yield* forkSideChat( + `side-chat-worktree-fork-${status}`, + parentId, + ThreadId.make(`side-chat-worktree-child-${status}`), + ).pipe(Effect.flip); + assert.include(String(rejected.cause), "has no worktree yet", status); + } + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("closing a side chat deletes it only while unused; a deleted parent takes no more", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("side-chat-close-parent"); + const unused = ThreadId.make("side-chat-close-unused"); + const used = ThreadId.make("side-chat-close-used"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-close-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + yield* forkSideChat("side-chat-close-fork-unused", parentId, unused); + yield* forkSideChat("side-chat-close-fork-used", parentId, used); + yield* askSideChat(used, "side-chat-close-used-message"); + const close = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`side-chat-close:${threadId}`), + threadId, + onlyIfUnused: true, + }); + + yield* close(unused); + yield* close(used); + assert.isNotNull((yield* orchestrator.getThreadProjection(unused)).thread.deletedAt); + assert.isNull((yield* orchestrator.getThreadProjection(used)).thread.deletedAt); + + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("side-chat-close-delete-parent"), + threadId: parentId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(used)).thread.deletedAt); + const orphan = yield* forkSideChat( + "side-chat-close-orphan", + parentId, + ThreadId.make("side-chat-close-orphan"), + ).pipe(Effect.flip); + assert.include(String(orphan.cause), "was deleted"); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("a side chat opened during the parent's first turn starts from a digest", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const grandparentId = ThreadId.make("side-chat-busy-grandparent"); + const grandparentRunId = RunId.make("side-chat-busy-grandparent-run"); + const parentId = ThreadId.make("side-chat-busy-parent"); + const sideChatId = ThreadId.make("side-chat-busy-child"); + yield* createParent(grandparentId); + yield* writeParentRun({ + threadId: grandparentId, + runId: grandparentRunId, + ordinal: 1, + status: "completed", + text: "GRANDPARENT_MARKER", + stable: true, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat-busy-parent-fork"), + sourceThreadId: grandparentId, + targetThreadId: parentId, + sourcePoint: { type: "run", runId: grandparentRunId }, + createdBy: "user", + creationSource: "web", + }); + yield* writeImportedHistory(parentId, "PARENT_IMPORTED_MARKER"); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-rewound-parent-run"), + ordinal: 1, + status: "rolled_back", + text: "PARENT_ROLLED_BACK_MARKER", + }); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-busy-parent-run"), + ordinal: 2, + status: "running", + text: "PARENT_RUNNING_MARKER", + }); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-queued-parent-run"), + ordinal: 3, + status: "queued", + text: "PARENT_QUEUED_MARKER", + }); + const fork = (commandId: string, sourceThreadId: ThreadId, targetThreadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(commandId), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + yield* fork("side-chat-busy-fork", parentId, sideChatId); + const created = yield* orchestrator.getThreadProjection(sideChatId); + assert.isTrue(created.thread.lineage.sideChat); + assert.equal(created.thread.runtimeMode, "approval-required"); + assert.isNull(created.thread.forkedFrom); + assert.deepEqual(created.contextTransfers[0]?.sourcePoint, { threadId: parentId }); + + const nested = yield* fork( + "side-chat-busy-nested", + sideChatId, + ThreadId.make("side-chat-busy-nested"), + ).pipe(Effect.flip); + assert.equal(nested._tag, "OrchestratorDispatchError"); + // Either would take the side chat's lock before the parent's, against the delete cascade. + const ontoParent = yield* fork("side-chat-busy-onto-parent", sideChatId, parentId).pipe( + Effect.flip, + ); + assert.equal(ontoParent._tag, "OrchestratorThreadAlreadyExistsError"); + const ontoExisting = yield* fork("side-chat-busy-onto-existing", parentId, sideChatId).pipe( + Effect.flip, + ); + assert.equal(ontoExisting._tag, "OrchestratorThreadAlreadyExistsError"); + const plainOntoExisting = yield* orchestrator + .dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat-busy-plain-onto-existing"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.equal(plainOntoExisting._tag, "OrchestratorThreadAlreadyExistsError"); + const busyMergeBack = yield* orchestrator + .dispatch({ + type: "thread.merge_back", + commandId: CommandId.make("side-chat-busy-merge-back"), + sourceThreadId: sideChatId, + targetThreadId: parentId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.include(String(busyMergeBack.cause), "active work"); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("side-chat-busy-first-message"), + threadId: sideChatId, + messageId: MessageId.make("side-chat-busy-first-message"), + text: "What is the main thread doing?", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const started = yield* orchestrator.getThreadProjection(sideChatId); + const [handoff] = started.contextHandoffs; + assert.lengthOf(started.contextHandoffs, 1); + assert.deepEqual(started.contextTransfers[0]?.resolution, { + strategy: "portable_context", + contextHandoffId: handoff!.id, + }); + assert.include(handoff!.summaryText, "You are in a side chat beside the main thread."); + // Inherited rows come first, so the cap truncates them first. + assert.isAbove( + handoff!.summaryText.indexOf("- User: PARENT_IMPORTED_MARKER"), + handoff!.summaryText.indexOf("- User: GRANDPARENT_MARKER"), + ); + assert.include(handoff!.summaryText, "- User: GRANDPARENT_MARKER"); + assert.notInclude(handoff!.summaryText, "PARENT_ROLLED_BACK_MARKER"); + assert.notInclude(handoff!.summaryText, "PARENT_QUEUED_MARKER"); + assert.include(handoff!.summaryText, "- User: PARENT_IMPORTED_MARKER"); + assert.include(handoff!.summaryText, "- User: PARENT_RUNNING_MARKER"); + assert.include(handoff!.summaryText, `t3_thread_read on thread ${parentId}`); + assert.equal( + started.messages.find((message) => message.role === "user")?.text, + "What is the main thread doing?", + ); + + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("side-chat-busy-delete-parent"), + threadId: parentId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(sideChatId)).thread.deletedAt); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("merge-back rejects a second fork's context until the first is consumed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("merge-back-guard-parent"); + const parentRunId = RunId.make("merge-back-guard-parent-run"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: parentRunId, + ordinal: 1, + status: "completed", + text: "Parent work", + }); + const forkAndRun = (name: string) => + Effect.gen(function* () { + const threadId = ThreadId.make(`merge-back-guard-${name}`); + const runId = RunId.make(`merge-back-guard-${name}-run`); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`merge-back-guard-fork-${name}`), + sourceThreadId: parentId, + targetThreadId: threadId, + sourcePoint: { type: "run", runId: parentRunId }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + yield* writeParentRun({ threadId, runId, ordinal: 1, status: "completed", text: name }); + return { threadId, runId }; + }); + const mergeBack = (commandId: string, fork: { threadId: ThreadId; runId: RunId }) => + orchestrator.dispatch({ + type: "thread.merge_back", + commandId: CommandId.make(commandId), + sourceThreadId: fork.threadId, + targetThreadId: parentId, + sourcePoint: { type: "run", runId: fork.runId }, + createdBy: "user", + creationSource: "web", + }); + const first = yield* forkAndRun("first"); + const second = yield* forkAndRun("second"); + + yield* mergeBack("merge-back-guard-first", first); + const rejected = yield* mergeBack("merge-back-guard-second", second).pipe(Effect.flip); + assert.include(String(rejected.cause), "pending context from another side chat or fork"); + yield* mergeBack("merge-back-guard-first-again", first); + // That pending merge-back targets the parent, so it does not block one into `first`. + const nested = { threadId: ThreadId.make("merge-back-guard-nested") }; + const nestedRunId = RunId.make("merge-back-guard-nested-run"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("merge-back-guard-fork-nested"), + sourceThreadId: first.threadId, + targetThreadId: nested.threadId, + sourcePoint: { type: "run", runId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* writeParentRun({ + threadId: nested.threadId, + runId: nestedRunId, + ordinal: 1, + status: "completed", + text: "nested", + }); + yield* orchestrator.dispatch({ + type: "thread.merge_back", + commandId: CommandId.make("merge-back-guard-nested"), + sourceThreadId: nested.threadId, + targetThreadId: first.threadId, + sourcePoint: { type: "run", runId: nestedRunId }, + createdBy: "user", + creationSource: "web", + }); + + const parent = yield* orchestrator.getThreadProjection(parentId); + assert.deepEqual( + parent.contextTransfers + .filter((transfer) => transfer.type === "merge_back") + .map((transfer) => [transfer.sourceThreadId, transfer.status]), + [ + [first.threadId, "superseded"], + [first.threadId, "pending"], + ], + ); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index e1bf75dd12846..6d401d968491d 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { CommandId, ContextTransferId, + EnvironmentId, MessageId, type ModelSelection, type OrchestrationV2AppThread, @@ -111,6 +112,7 @@ function makeSourceProjection(sourceRun: OrchestrationV2Run): OrchestrationV2Thr const planFork = ( sourceRun: OrchestrationV2Run, sourceProjection = makeSourceProjection(sourceRun), + options: { readonly sideChat?: boolean } = {}, ) => Effect.gen(function* () { const service = yield* ThreadForkService.ThreadForkServiceV2; @@ -125,6 +127,7 @@ const planFork = ( transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"), targetThreadId, title: "Awake fork", + ...options, createdBy: "user", creationSource: "mobile", createdAt: forkCreatedAt, @@ -261,3 +264,63 @@ it.effect("rejects in-progress and rolled-back fork sources", () => } }), ); + +it.effect("marks only requested forks as supervised side chats", () => + Effect.gen(function* () { + const sourceRun = makeSourceRun("completed"); + const sideChat = yield* planFork(sourceRun, makeSourceProjection(sourceRun), { + sideChat: true, + }); + assert.isTrue(sideChat.targetThread.lineage.sideChat); + assert.equal(sideChat.targetThread.runtimeMode, "approval-required"); + + const sideChatSource = makeSourceProjection(sourceRun); + const sideChatProjection = { + ...sideChatSource, + thread: { + ...sideChatSource.thread, + lineage: { ...sideChatSource.thread.lineage, sideChat: true }, + }, + }; + const forkOfSideChat = yield* planFork(sourceRun, sideChatProjection); + assert.isUndefined(forkOfSideChat.targetThread.lineage.sideChat); + assert.equal(forkOfSideChat.targetThread.runtimeMode, "full-access"); + + const nested = yield* planFork(sourceRun, sideChatProjection, { sideChat: true }).pipe( + Effect.flip, + ); + assert.equal(nested.cause, "A side chat cannot start another side chat."); + + const subagentSource = makeSourceProjection(sourceRun); + const fromSubagent = yield* planFork( + sourceRun, + { + ...subagentSource, + thread: { + ...subagentSource.thread, + lineage: { ...subagentSource.thread.lineage, relationshipToParent: "subagent" }, + }, + }, + { sideChat: true }, + ).pipe(Effect.flip); + assert.equal(fromSubagent.cause, "A subagent cannot start a side chat."); + + const delegatedSource = makeSourceProjection(sourceRun); + const fromDelegated = yield* planFork( + sourceRun, + { + ...delegatedSource, + thread: { + ...delegatedSource.thread, + delegatedFrom: { + environmentId: EnvironmentId.make("linked-environment"), + threadId: ThreadId.make("linked-parent"), + title: "Linked parent", + }, + }, + }, + { sideChat: true }, + ).pipe(Effect.flip); + assert.equal(fromDelegated.cause, "A subagent cannot start a side chat."); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 71ff9764868ad..4717daf75e30c 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -51,13 +51,15 @@ export function forkableSourceRunStatusError( export interface ThreadForkServiceV2Shape { readonly plan: (input: { readonly sourceProjection: Pick; - readonly sourceRun: OrchestrationV2Run; + /** Null only for a side chat whose parent has no stable run yet. */ + readonly sourceRun: OrchestrationV2Run | null; readonly sourceProviderThread: OrchestrationV2ProviderThread | undefined; readonly sourceTokenUsage?: OrchestrationV2ProviderTurnTokenUsage | undefined; readonly canonicalSourcePoint: OrchestrationV2ContextSourcePoint; readonly transferId: ContextTransferId; readonly targetThreadId: ThreadId; readonly title?: string; + readonly sideChat?: boolean; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; readonly createdAt: DateTime.Utc; @@ -73,11 +75,28 @@ export const layer: Layer.Layer = Layer.succeed( ThreadForkServiceV2, ThreadForkServiceV2.of({ plan: Effect.fn("ThreadForkService.plan")(function* (input) { - if (!isForkableSourceRunStatus(input.sourceRun.status)) { + const { sourceRun } = input; + const sourceLineage = input.sourceProjection.thread.lineage; + // A task a linked environment delegated here is a subagent of a remote thread. + if ( + input.sideChat && + (sourceLineage.sideChat || + sourceLineage.relationshipToParent === "subagent" || + input.sourceProjection.thread.delegatedFrom !== undefined) + ) { return yield* new ThreadForkPlanError({ sourceThreadId: input.sourceProjection.thread.id, targetThreadId: input.targetThreadId, - cause: forkableSourceRunStatusError(input.sourceRun), + cause: sourceLineage.sideChat + ? "A side chat cannot start another side chat." + : "A subagent cannot start a side chat.", + }); + } + if (sourceRun !== null && !isForkableSourceRunStatus(sourceRun.status)) { + return yield* new ThreadForkPlanError({ + sourceThreadId: input.sourceProjection.thread.id, + targetThreadId: input.targetThreadId, + cause: forkableSourceRunStatusError(sourceRun), }); } const targetThread: OrchestrationV2AppThread = { @@ -92,17 +111,19 @@ export const layer: Layer.Layer = Layer.succeed( title: input.title ?? `${input.sourceProjection.thread.title} fork`, // The source's title job completes only on the source thread. titleRegeneration: null, + // Side chats share the parent's worktree, so they start supervised. + ...(input.sideChat ? { runtimeMode: "approval-required" as const } : {}), activeProviderThreadId: null, lineage: { parentThreadId: input.sourceProjection.thread.id, relationshipToParent: "fork", rootThreadId: input.sourceProjection.thread.lineage.rootThreadId, + ...(input.sideChat ? { sideChat: true } : {}), }, - forkedFrom: { - type: "run", - threadId: input.sourceProjection.thread.id, - runId: input.sourceRun.id, - }, + forkedFrom: + sourceRun === null + ? null + : { type: "run", threadId: input.sourceProjection.thread.id, runId: sourceRun.id }, createdAt: input.createdAt, updatedAt: input.createdAt, archivedAt: null, @@ -124,10 +145,11 @@ export const layer: Layer.Layer = Layer.succeed( targetThreadId: input.targetThreadId, sourcePoint: input.canonicalSourcePoint, basePoint: null, - sourceProviderInstanceId: input.sourceRun.providerInstanceId, + sourceProviderInstanceId: + sourceRun?.providerInstanceId ?? input.sourceProjection.thread.providerInstanceId, // Only native forks retain this context; portable handoffs rebuild it. sourceTokenUsage: - (input.sourceRun.status === "completed" || input.sourceRun.status === "waiting") && + (sourceRun?.status === "completed" || sourceRun?.status === "waiting") && input.sourceProviderThread?.nativeThreadRef?.strength === "strong" ? input.sourceTokenUsage : undefined, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index cb361bec6b215..79a6761464394 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -86,6 +86,8 @@ const project = { defaultModelSelection: modelSelection, defaultThreadEnvMode: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", deletedAt: null, @@ -251,6 +253,8 @@ function makeHarness(options: HarnessOptions = {}) { faviconPath: null, projectIcon: null, scripts: project.scripts, + pinnedAt: null, + archivedAt: null, createdAt: project.createdAt, updatedAt: project.updatedAt, deletedAt: project.deletedAt, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index fa07b76f93a63..424762fd36b06 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -11,6 +11,7 @@ import type { import { type ChatAttachment, CommandId, + isListedThreadLineage, MessageId, OrchestrationV2StopWorkflowError, type OrchestrationV2StopWorkflowInput, @@ -419,7 +420,7 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; - /** Omit `projectId` to list every project's threads. */ + /** Omit `projectId` to list every project's threads. Side chats are never listed. */ readonly listProjectThreads: (input: { readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; @@ -794,9 +795,10 @@ const make = Effect.gen(function* () { ), Effect.map((snapshot) => snapshot.threads - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", + .filter((thread) => + input.includeSubagents + ? thread.lineage.sideChat !== true + : isListedThreadLineage(thread.lineage), ) .toSorted( (left, right) => diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 5d816133c89c7..b8d75340ec6bc 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -66,6 +66,7 @@ const thread = ( readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc; readonly subagentOf?: ThreadId; + readonly sideChatOf?: ThreadId; readonly title?: string; readonly settledAt?: DateTime.Utc; readonly linkedPullRequest?: ThreadLinkedPullRequest; @@ -98,7 +99,14 @@ const thread = ( relationshipToParent: "subagent", rootThreadId: overrides.subagentOf, } - : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + : overrides.sideChatOf + ? { + parentThreadId: overrides.sideChatOf, + relationshipToParent: "fork", + rootThreadId: overrides.sideChatOf, + sideChat: true, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, createdAt: at(0), updatedAt: at(0), @@ -366,13 +374,14 @@ it.layer(layerTest)("ThreadSearch", (it) => { }), ); - it.effect("matches inside identifiers and skips subagent threads and short tokens", () => + it.effect("matches inside identifiers and skips hidden threads and short tokens", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const search = yield* ThreadSearch.ThreadSearch; const project = ProjectId.make("project:search-tokens"); const parent = ThreadId.make("thread:tokens-parent"); const subagent = ThreadId.make("thread:tokens-subagent"); + const sideChat = ThreadId.make("thread:tokens-side-chat"); yield* createProject(project); yield* Effect.forEach( [ @@ -380,6 +389,8 @@ it.layer(layerTest)("ThreadSearch", (it) => { message(parent, "parent", "user", "Rename ThreadSearchService before shipping"), thread(subagent, project, { subagentOf: parent }), message(subagent, "subagent", "assistant", "ThreadSearchService renamed"), + thread(sideChat, project, { sideChatOf: parent, title: "ThreadSearchService question" }), + message(sideChat, "side-chat", "user", "Why rename ThreadSearchService?"), ], projections.apply, { discard: true }, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 62c7f4eed45ec..3c97122c1c63e 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -154,7 +154,7 @@ export function buildSearchSnippet(text: string, matchStart: number): string { return `${start > 0 ? "…" : ""}${shown.slice(0, end)}${cut ? "…" : ""}`; } -// Hidden subagent threads must be filtered before the hit limit so visible matches aren't lost. +// Filter hidden subagents and side chats before the hit limit so visible matches aren't lost. // Word starts and ranking follow the title rules in JS. // Without FTS5 trigrams, or for two-character CJK words, scan the same eligible messages. // The scan folds ASCII case only, so accent variants and non-ASCII case variants may not match. @@ -174,6 +174,7 @@ export const messageHitsQuery = ( AND threads.thread_id NOT IN (SELECT value FROM json_each(${excludedThreadIds})) AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 ) ${ match === null @@ -332,6 +333,7 @@ export const make = Effect.gen(function* () { AND projects.deleted_at IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND (${projectId} IS NULL OR threads.project_id = ${projectId}) `, }); @@ -362,6 +364,7 @@ export const make = Effect.gen(function* () { AND projects.deleted_at IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' AND json_extract(threads.payload_json, '$.settledAt') < ${before} AND (${projectIds} IS NULL @@ -384,6 +387,7 @@ export const make = Effect.gen(function* () { AND json_extract(threads.payload_json, '$.archivedAt') IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' AND json_extract(threads.payload_json, '$.settledAt') < ${before} AND (${projectIds} IS NULL diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index a5059eada443b..5c9fd310e93ae 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,8 +1,13 @@ import { + ContextHandoffId, + ContextTransferId, EventId, OrchestrationV2ThreadStreamItem, + ProviderThreadId, + RunId, ThreadId, TurnItemId, + type OrchestrationV2ContextHandoff, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, @@ -149,11 +154,31 @@ describe("decideThreadResume", () => { }); it("builds socket fallback snapshots with a bounded timeline and history cursor", () => { + const projection = timelineProjection(80); + // A delivered side chat digest stays "ready", which bounded snapshots keep. + const sideChatHandoff = { + id: ContextHandoffId.make("side-chat-handoff"), + transferId: ContextTransferId.make("side-chat-transfer"), + threadId: THREAD_ID, + targetRunId: RunId.make("side-chat-run"), + fromProviderThreadIds: [], + toProviderThreadId: ProviderThreadId.make("side-chat-provider-thread"), + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: "x".repeat(8_000), + delivery: { nativeThreadId: "native", status: "inline", itemIds: [] }, + createdByProviderInstanceId: null, + createdAt: NOW, + updatedAt: NOW, + } satisfies OrchestrationV2ContextHandoff; const snapshot = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, - projection: timelineProjection(80), + projection: { ...projection, contextHandoffs: [sideChatHandoff] }, }); + expect(snapshot.projection.contextHandoffs.map((handoff) => handoff.summaryText)).toEqual([""]); expect(snapshot.kind).toBe("snapshot"); expect(snapshot.snapshotSequence).toBe(44); expect(snapshot.projection.visibleTurnItems).toHaveLength(75); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8300d50acb021..8f7de819f5718 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -82,6 +82,8 @@ function makeHarness( faviconPath: null, projectIcon: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts index 2e077314b8212..430018adcb06a 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.test.ts @@ -1,5 +1,5 @@ -import { assert, it } from "@effect/vitest"; -import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { RunId, ThreadId, type OrchestrationV2DomainEvent } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -14,6 +14,7 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; +import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; it.effect("idle sweeps skip SQL until the next wake time or a relevant event", () => Effect.gen(function* () { @@ -68,3 +69,50 @@ it.effect("idle sweeps skip SQL until the next wake time or a relevant event", ( }).pipe(Effect.provide(layer)); }), ); + +const HOUR_MS = 60 * 60 * 1000; +const nowMs = Date.parse("2026-10-19T12:00:00.000Z"); + +const failedThread = (failedAgoMs: number, resetAgoMs: number) => { + const failedAt = DateTime.makeUnsafe(nowMs - failedAgoMs); + const candidate: ProjectionStore.ProjectionLimitRecoveryCandidate = { + id: ThreadId.make("thread:limited"), + status: "failed", + lastErrorClass: "usage_limit", + usageLimitResetAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs - resetAgoMs)), + latestRunId: RunId.make("run:limited"), + latestRunCompletedAt: failedAt, + updatedAt: failedAt, + archivedAt: null, + settledOverride: null, + pendingRuntimeRequest: null, + limitRecovery: null, + snoozedUntil: null, + }; + return candidate; +}; + +describe("limitRecoveryCommand arming", () => { + it("does not freshly arm a failure whose reset passed days ago", () => { + const thread = failedThread(5 * 24 * HOUR_MS, 4 * 24 * HOUR_MS); + assert.isNull(limitRecoveryCommand(thread, true, nowMs)); + assert.isNull(limitRecoveryCommand(thread, true, nowMs, true)); + }); + + it("still arms a failure whose reset only just passed", () => { + const thread = failedThread(6 * HOUR_MS, HOUR_MS); + const arm = limitRecoveryCommand(thread, true, nowMs); + assert.equal(arm?.type, "thread.metadata.update"); + }); + + it("arms a reset that is exactly 24 hours overdue", () => { + const thread = failedThread(30 * HOUR_MS, 24 * HOUR_MS); + const arm = limitRecoveryCommand(thread, true, nowMs); + assert.equal(arm?.type, "thread.metadata.update"); + }); + + it("does not arm a reset that is 24 hours and 1ms overdue", () => { + const thread = failedThread(30 * HOUR_MS, 24 * HOUR_MS + 1); + assert.isNull(limitRecoveryCommand(thread, true, nowMs)); + }); +}); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index 224eecbbe5f69..e46ac34c8be61 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -12,6 +12,8 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; +const MAX_OVERDUE_ARM_MS = 24 * 60 * 60 * 1000; + export function limitRecoveryCommand( thread: ProjectionStore.ProjectionLimitRecoveryCandidate, autoResume: boolean, @@ -38,6 +40,7 @@ export function limitRecoveryCommand( const identity = `${thread.id}:${thread.latestRunId}:${resetMs}`; const recovery = thread.limitRecovery; if (recovery?.runId !== thread.latestRunId || recovery.resetAt !== thread.usageLimitResetAt) { + if (nowMs - resetMs > MAX_OVERDUE_ARM_MS) return null; if (!autoResume && (!snooze || resetMs <= nowMs)) return null; return { type: "thread.metadata.update", diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 387bacb478e04..9577f9271c730 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -10,7 +10,7 @@ import * as DateTime from "effect/DateTime"; /** Bounds consecutive comment-only wakes to stop bot reply loops; check, conflict, or push news resets it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; /** How long a head may go without any check before the agent is told none reported. */ -const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; +export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c7a4b384c8e40..8ac7490357c3e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -590,6 +590,220 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i ); }), ); + + it.effect( + "deletes a side chat with its parent only after the side chat's in-flight command", + () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectId = ProjectId.make("runtime-side-chat-delete"); + const parentId = ThreadId.make("runtime-side-chat-delete-parent"); + const sideChatId = ThreadId.make("runtime-side-chat-delete-child"); + const updateCommandId = CommandId.make("runtime-side-chat-delete-update"); + yield* projects.create({ + commandId: CommandId.make("runtime-side-chat-delete-project"), + projectId, + title: "Side chat deletion", + workspaceRoot: "/work/side-chat-deletion", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-side-chat-delete-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId, + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-delete-fork"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + const updateReady = yield* Deferred.make(); + const releaseUpdate = yield* Deferred.make(); + const deletionQueued = yield* Deferred.make(); + const commitCommand = eventSink.commitCommand; + const withLock = executor.withLock; + let sideChatLockRequests = 0; + const commitSpy = vi + .spyOn(eventSink, "commitCommand") + .mockImplementation((input) => + input.commandId === updateCommandId + ? Deferred.succeed(updateReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseUpdate)), + Effect.andThen(commitCommand(input)), + ) + : commitCommand(input), + ); + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => { + if (key !== sideChatId || ++sideChatLockRequests !== 2) return withLock(key, effect); + return Deferred.succeed(deletionQueued, undefined).pipe( + Effect.andThen(withLock(key, effect)), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + const updateFiber = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: updateCommandId, + threadId: sideChatId, + title: "Updated before deletion", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst( + Deferred.await(updateReady), + Fiber.join(updateFiber).pipe( + Effect.andThen( + Effect.die("The update completed before reaching its commit barrier."), + ), + ), + ); + const deleteFiber = yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("runtime-side-chat-delete-parent"), + threadId: parentId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst( + Deferred.await(deletionQueued), + Fiber.join(deleteFiber).pipe( + Effect.andThen(Effect.die("Parent deletion bypassed the side chat's command.")), + ), + ); + yield* Deferred.succeed(releaseUpdate, undefined); + yield* Fiber.join(updateFiber); + yield* Fiber.join(deleteFiber); + const sideChat = yield* orchestrator.getThreadProjection(sideChatId); + assert.isNotNull(sideChat.thread.deletedAt); + assert.equal(sideChat.thread.title, "Updated before deletion"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + commitSpy.mockRestore(); + lockSpy.mockRestore(); + }), + ), + ); + }), + ); + + // A fork from a side chat onto its parent used to lock the side chat, then the parent: the + // reverse of the parent delete's order. + it.effect("a parent delete and a fork from its side chat onto the parent do not deadlock", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectId = ProjectId.make("runtime-side-chat-lock-order"); + const parentId = ThreadId.make("runtime-side-chat-lock-order-parent"); + const sideChatId = ThreadId.make("runtime-side-chat-lock-order-child"); + yield* projects.create({ + commandId: CommandId.make("runtime-side-chat-lock-order-project"), + projectId, + title: "Side chat lock order", + workspaceRoot: "/work/side-chat-lock-order", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-side-chat-lock-order-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId, + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-lock-order-fork"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + const deleteHoldsParent = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + const forkWaitsForParent = yield* Deferred.make(); + const withLock = executor.withLock; + let parentLockRequests = 0; + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => { + if (key !== parentId) return withLock(key, effect); + // Dispatch builds locks it may not run, so count when one runs. + return Effect.suspend(() => + ++parentLockRequests === 1 + ? withLock( + key, + Deferred.succeed(deleteHoldsParent, undefined).pipe( + Effect.andThen(Deferred.await(releaseDelete)), + Effect.andThen(effect), + ), + ) + : Deferred.succeed(forkWaitsForParent, undefined).pipe( + Effect.andThen(withLock(key, effect)), + ), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + const deleteFiber = yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("runtime-side-chat-lock-order-delete"), + threadId: parentId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deleteHoldsParent); + const forkFiber = yield* orchestrator + .dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-lock-order-fork-back"), + sourceThreadId: sideChatId, + targetThreadId: parentId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(forkWaitsForParent); + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + yield* Fiber.join(forkFiber); + assert.isNotNull((yield* orchestrator.getThreadProjection(parentId)).thread.deletedAt); + assert.isNotNull((yield* orchestrator.getThreadProjection(sideChatId)).thread.deletedAt); + }).pipe(Effect.ensuring(Effect.sync(() => lockSpy.mockRestore()))); + }), + ); }); const layerSharedApplicationDataPlaneTest = Layer.mergeAll( @@ -3518,6 +3732,113 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([true, false])( + "reads a quiet head when its no-checks notice comes due (fingerprint: %s)", + (fingerprinted) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make(`runtime-pull-request-watch-due-${fingerprinted}`); + const projectId = ProjectId.make(`pr-watch-due-project-${fingerprinted}`); + yield* seedProject({ + projectId, + title: "Watch no-checks deadline", + workspaceRoot: "/workspace/watch-due", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-due-create-${fingerprinted}`), + threadId, + projectId, + title: "Watch no-checks deadline", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 14 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-due-start-${fingerprinted}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, + }); + + let reads = 0; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + // The host reports the same fingerprint throughout, so only the deadline can prompt a read. + watchFingerprint: () => + Effect.succeed(fingerprinted ? { status: "quiet", remarks: "none" } : null), + invalidate: () => Effect.void, + detail: () => + Effect.sync(() => { + reads++; + return { + ...watchedPullRequestDetail({ + projectId, + number: 14, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "6074dd48ff", + checks: [], + }; + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const wakes = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => + messages.filter((message) => message.text.includes("No checks have reported")), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); + + yield* sweep; + yield* TestClock.adjust("10 minutes"); + yield* sweep; + assert.lengthOf(yield* wakes, 0); + + yield* TestClock.adjust("6 minutes"); + yield* sweep; + const told = yield* wakes; + assert.lengthOf(told, 1); + assert.include(told[0]!.text, "after 16 minutes"); + + // The notice clears the deadline, so the watch returns to its quiet cadence. + const readsAfterNotice = reads; + yield* TestClock.adjust("2 minutes"); + yield* sweep; + assert.equal(reads, readsAfterNotice); + assert.lengthOf(yield* wakes, 1); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-due-end-${fingerprinted}`), + threadId, + ...key, + watching: false, + }); + yield* worker.drain(); + }), + ); + // regression: #21155 watching a pull request again repeated news the thread already got it.effect("watching a dismissed stack layer again keeps what its last watch told", () => Effect.gen(function* () { diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b18280483868e..bdfc0488a60ee 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,7 @@ import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; +import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; // Coder: T3 Coder has no auth sessions or pairing. Upstream's auth migrations keep // their IDs so later upstream migrations stay aligned, but they create nothing. @@ -156,6 +157,7 @@ export const migrationEntries = [ [73, "ActiveTurnItemIndexes", Migration0073], [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], + [76, "ProjectionProjectsPinArchive", Migration0076], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts b/apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts new file mode 100644 index 0000000000000..279255ec4d5f9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts @@ -0,0 +1,9 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +// Null means unpinned and not archived. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_projects ADD COLUMN pinned_at TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN archived_at TEXT`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4fefa61ec010f..08c5094bf49c8 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -55,6 +55,7 @@ describe("V2 preview upgrade", () => { [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], + [76, "ProjectionProjectsPinArchive"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -153,6 +154,7 @@ describe("V2 preview upgrade", () => { [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], + [76, "ProjectionProjectsPinArchive"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 903e9a7010094..3dee90313a2b3 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -62,6 +62,17 @@ const layerProbeFailure = ( const LSOF_TEST_PORT = 43_123; +const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + const ownedScanner = (processIds: ReadonlyArray = [1234]) => Effect.tap(PortScanner.PortDiscovery, (scanner) => scanner.registerTerminalProcesses({ @@ -174,10 +185,11 @@ effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites" effectIt.effect("projects configured paths independently for simultaneous subscribers", () => { const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + let ready = true; const fetchFn = ((input: Parameters[0]) => { const url = String(input); return Promise.resolve( - url === docsUrl || url === adminUrl + ready && (url === docsUrl || url === adminUrl) ? new Response("app", { headers: { "content-type": "text/html" } }) : new Response("not found", { status: 404 }), ); @@ -185,7 +197,7 @@ effectIt.effect("projects configured paths independently for simultaneous subscr const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const docsSnapshots: ReadonlyArray[] = []; const adminSnapshots: ReadonlyArray[] = []; yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) => @@ -198,6 +210,11 @@ effectIt.effect("projects configured paths independently for simultaneous subscr expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); + + ready = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); + expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -289,28 +306,45 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { - const redirects: Array = []; - const fetchFn = (async ( - input: Parameters[0], - init?: Parameters[1], - ) => { - redirects.push(init?.redirect); - if (String(input).startsWith("http:")) throw new TypeError("TLS listener"); - return new Response(null, { status: 302, headers: { location: "https://example.com" } }); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); +effectIt.effect( + "probes only a configured HTTPS URL, keeps it for an owned listener when the check fails, and does not follow redirects", + () => { + const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; + const requests: Array = []; + let respond = async () => + new Response(null, { status: 302, headers: { location: "https://example.com" } }); + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push([String(input), init?.redirect]); + return respond(); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - const servers = yield* scanner.scan(); - expect(servers).toHaveLength(1); - expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); - expect(redirects).toEqual(["manual", "manual"]); - }).pipe(Effect.provide(layer)); -}); + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + + respond = async () => new Response("unavailable", { status: 503 }); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); -effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + respond = () => Promise.reject(new TypeError("fetch failed")); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + expect(requests).toEqual(Array.from({ length: 3 }, () => [configuredUrl, "manual"])); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => { const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; let stdout = unowned; @@ -330,19 +364,19 @@ effectIt.effect("probes a port only while T3 terminals own every listener on it" stdout = owned; expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + expect(requests).toEqual([]); yield* scanner.unregisterTerminal({ threadId: "scanner-thread", terminalId: "scanner-terminal", }); expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toHaveLength(1); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); effectIt.effect( - "excludes HTTP errors, non-navigation responses, and successful non-documents", + "excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents", () => { let pid = 1; let makeResponse = () => @@ -350,33 +384,35 @@ effectIt.effect( const fetchFn = ((_input: Parameters[0]) => Promise.resolve(makeResponse())) as typeof globalThis.fetch; const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/`; return Effect.gen(function* () { - const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); - expect(yield* scanner.scan()).toHaveLength(0); + const scanner = yield* PortScanner.PortDiscovery; + const scan = () => scanner.scan([configuredUrl]); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 204, headers: { "content-type": "text/html" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 302 }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => @@ -384,7 +420,7 @@ effectIt.effect( status: 200, headers: { "content-type": "application/xhtml+xml; charset=utf-8" }, }); - expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scan()).toHaveLength(1); }).pipe(Effect.provide(layer)); }, ); @@ -433,10 +469,11 @@ effectIt.effect( return target === undefined ? missing.readLink(path) : Effect.succeed(target); }, }); - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = layerProbeFailure( (input) => { lsofSpawns += 1; @@ -449,37 +486,45 @@ effectIt.effect( return Effect.gen(function* () { const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); - expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 8765, pid: 4242, processName: "python3" }, - ]); + expect( + first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })), + ).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]); yield* scanner.scan(); expect(lsofSpawns).toBe(1); expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); yield* scanner.scan(); yield* scanner.scan(); expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }, ); effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + let ipv6Fails = true; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => { + if (input.command === "tasklist.exe") { + return Effect.succeed( + processOutput('"node.exe","1234","Console","1","50,000 K"\r\n'), + ); + } const isIpv6 = input.args.includes("tcpv6"); return Effect.succeed({ stdout: isIpv6 ? "" : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n", stderr: "", - code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null, + code: isIpv6 && ipv6Fails ? ChildProcessSpawner.ExitCode(1) : null, timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -502,20 +547,14 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits processIds: [1234], }); expect(yield* scanner.scan()).toEqual([]); + ipv6Fails = false; + expect(yield* scanner.scan()).toMatchObject([ + { port: 5173, pid: 1234, processName: "node", url: "http://localhost:5173" }, + ]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); -const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ - stdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, -}); - effectIt.effect("rechecks the socket owner before stopping a Linux server without lsof", () => { let holder = "4242"; const missing = FileSystem.makeNoop({}); @@ -569,6 +608,63 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou }).pipe(Effect.provide(layer)); }); +effectIt.effect.each(["linux", "darwin", "win32"] as const)( + "reads no listeners on %s while T3CODE_PORT_DISCOVERY=0, and still probes configured URLs", + (platform) => { + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const commands: string[] = []; + const fileReads: string[] = []; + const requests: string[] = []; + const missing = FileSystem.makeNoop({}); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + fileReads.push(path); + return missing.readFileString(path); + }, + readDirectory: (path) => { + fileReads.push(path); + return missing.readDirectory(path); + }, + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + commands.push(input.command); + return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`)); + }, + }), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Environment, { T3CODE_PORT_DISCOVERY: "0" }), + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch, (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response("docs", { headers: { "content-type": "text/html" } }), + ); + }), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + scanner.trackAgent(); + expect(yield* scanner.scan()).toEqual([]); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { port: LSOF_TEST_PORT, pid: null, terminal: null, url: configuredUrl }, + ]); + expect(requests).toEqual([configuredUrl]); + expect(commands).toEqual([]); + expect(fileReads).toEqual([]); + }).pipe(Effect.provide(layer)); + }, +); + effectIt.effect.each(["linux", "darwin"] as const)( "attributes listeners to the agent thread in their environment on %s, after the agent exits", (platform) => { @@ -672,7 +768,7 @@ effectIt.effect.each(["linux", "darwin"] as const)( expect(yield* scanner.scan()).toMatchObject([ { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, ]); - expect(requests).toEqual([`http://localhost:${agentPort}/`]); + expect(requests).toEqual([]); expect(yield* scanner.scan()).toHaveLength(1); expect(environmentReads).toBe(2); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 1080624d7508d..e026b204c03d8 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -68,6 +68,9 @@ export class PortDiscovery extends Context.Service< */ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; +/** `0` turns off listener discovery; configured preview URLs are still probed. */ +const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; + const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; @@ -132,8 +135,8 @@ interface WebProbeCacheEntry { interface WebProbeGroup { readonly server: DiscoveredLocalServer; - readonly urls: ReadonlyArray; - readonly configuredKey: string | null; + readonly url: string; + readonly configuredKey: string; } interface WebProbeSnapshot { @@ -189,17 +192,22 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const firstConfiguredByServer = new Map(); for (const raw of normalizeConfiguredUrls(configuredUrls)) { const url = new URL(raw); const port = urlPort(url); const serverKey = localServerKey(url.hostname, port); + if (!firstConfiguredByServer.has(serverKey)) firstConfiguredByServer.set(serverKey, raw); if (visibleByServer.has(serverKey)) continue; const configured = snapshot.configured.get(webProbeCacheKey(raw)); if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); } + // An owned listener whose configured check failed still opens at this caller's configured URL. for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); - if (!visibleByServer.has(key)) visibleByServer.set(key, server); + if (visibleByServer.has(key)) continue; + const configuredUrl = firstConfiguredByServer.get(key); + visibleByServer.set(key, configuredUrl ? { ...server, url: configuredUrl } : server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); }; @@ -498,6 +506,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; + const listenerDiscovery = (yield* HostProcess.Environment)[PORT_DISCOVERY_ENV] !== "0"; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ listeners: new Map(), @@ -715,24 +724,17 @@ export const make = Effect.gen(function* PortDiscoveryMake() { pid: null, terminal: null, }, - urls: [raw], + url: raw, configuredKey: resourceKey, }); } - for (const server of servers) { - // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. - if (!isOwned(server)) continue; - groups.push({ - server, - urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], - configuredKey: null, - }); - } - return groups; }; + // Only configured URLs are requested. Owned listeners are listed as plain HTTP without a + // request: owning a process says nothing about its protocol, and HTTP or TLS bytes break + // binary ones such as Unity's test runner or Steam's IPC. const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray, configuredUrls: ReadonlyArray, @@ -740,77 +742,37 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); - const batchProbes = new Map< - string, - Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> - >(); - const batchProbeSemaphore = yield* Semaphore.make(1); - const getProbe = (url: string, pid: number | null) => { - const key = webProbeCacheKey(url); - const identity = `${key}\u0000${pid ?? ""}`; - return batchProbeSemaphore - .withPermits(1)( - Effect.gen(function* () { - const existing = batchProbes.get(identity); - if (existing) return [existing] as const; - const cachedProbe = cached.get(key); - const cachedIsCurrent = - cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; - const memoized = yield* Effect.cached( - cachedIsCurrent - ? Effect.succeed({ probe: cachedProbe, fresh: false }) - : probeWebUrl(url).pipe( - Effect.map((result) => ({ - probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, - fresh: true, - })), - ), - ); - batchProbes.set(identity, memoized); - return [memoized] as const; - }), - ) - .pipe(Effect.flatMap(([probe]) => probe)); - }; const probed = yield* Effect.forEach( groups, - (group) => - Effect.gen(function* () { - const probes: Array = []; - let visibleUrl: string | null = null; - for (const url of group.urls) { - const key = webProbeCacheKey(url); - const { probe, fresh } = yield* getProbe(url, group.server.pid); - probes.push([key, probe, fresh]); - if (probe.isWeb) { - visibleUrl = url; - break; - } - } - return { group, probes, visibleUrl }; - }), + (group) => { + const { pid } = group.server; + const cachedProbe = cached.get(group.configuredKey); + return cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis + ? Effect.succeed({ group, probe: cachedProbe, fresh: false }) + : probeWebUrl(group.url).pipe( + Effect.map((result) => ({ + group, + probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, + fresh: true, + })), + ); + }, { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); - const discovered: DiscoveredLocalServer[] = []; const configured = new Map(); - for (const { group, probes, visibleUrl } of probed) { - for (const [key, probe, fresh] of probes) { - nextCache.set( - key, - fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, - ); - } - if (visibleUrl === null) continue; - const server = { ...group.server, url: visibleUrl }; - if (group.configuredKey === null) discovered.push(server); - else configured.set(group.configuredKey, server); + for (const { group, probe, fresh } of probed) { + nextCache.set( + group.configuredKey, + fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, + ); + if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url }); } yield* Ref.set(webProbeCacheRef, nextCache); - return { discovered, configured } satisfies WebProbeSnapshot; + return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -873,6 +835,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray, ) { + if (!listenerDiscovery) return yield* probeWebServers([], configuredUrls); const state = yield* Ref.get(stateRef); const terminalOwners = new Map(); for (const registration of state.terminalProcesses.values()) { diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 20488823366a7..043b7ad1044b0 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1249,6 +1249,56 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("titles Codex sessions with their newest saved Codex name", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-named.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "named-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: + '{"page_id":null}\nHey, what\'s up?', + }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + yield* fileSystem.writeFileString( + path.join(codexHomePath, "session_index.jsonl"), + [ + { id: "named-session", thread_name: "First name" }, + { id: "other-session", thread_name: "Other chat" }, + { id: "named-session", thread_name: "Renamed chat" }, + { id: "named-session", thread_name: " " }, + ] + .map((entry) => `${encodeTranscriptRecord({ ...entry, updated_at: "unknown" })}\n`) + .join(""), + ); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.title)).toEqual(["Renamed chat"]); + }), + ); + it.effect("excludes Codex subagents from discovery and the recent-thread budget", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 0d60793cb2610..f0a0627f3925e 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -62,6 +62,7 @@ function resolvePiSessionsDir( const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; const METADATA_READ_BYTES = 8 * 1024; const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; +const MAX_CODEX_SESSION_INDEX_BYTES = 1024 * 1024; const MAX_TRANSCRIPTS_PER_SOURCE = 5000; @@ -124,6 +125,11 @@ const TranscriptRecord = Schema.Struct({ ), }); +const CodexSessionIndexEntry = Schema.Struct({ + id: Schema.String, + thread_name: Schema.String, +}); + const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); @@ -137,6 +143,9 @@ const decodePiEntryLinks = Schema.decodeUnknownOption( ); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); +const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( + Schema.fromJsonString(CodexSessionIndexEntry), +); type DecodedTranscriptRecord = typeof TranscriptRecord.Type; @@ -191,6 +200,7 @@ interface RawCandidate { readonly cwd: string; readonly source: AgentSessionSource; readonly providerInstanceId: ProviderInstanceId; + readonly homePath: string; readonly threadCount: number; readonly lastActiveAtMs: number | null; readonly transcripts: ReadonlyArray<{ @@ -203,6 +213,7 @@ interface TranscriptCandidate { readonly filePath: string; readonly mtimeMs: number; readonly providerInstanceId: ProviderInstanceId; + readonly homePath: string; readonly size: number; } @@ -311,6 +322,17 @@ function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord) const CODEX_TITLE_PREAMBLE = /^(?:\s*(?:<(recommended_plugins|environment_context|user_instructions|INSTRUCTIONS|in-app-browser-context)(?:\s[^>]*)?>[\s\S]*?<\/\1>|# AGENTS\.md instructions\b[^\n]*|## My request for Codex:))+/; +// Codex appends an entry per rename; like Codex, the newest nonempty name wins. +function parseCodexThreadNames(contents: string): ReadonlyMap { + const names = new Map(); + for (const line of contents.split("\n")) { + const entry = Option.getOrUndefined(decodeCodexSessionIndexEntry(line)); + const name = entry?.thread_name.trim(); + if (entry !== undefined && name) names.set(entry.id, name); + } + return names; +} + function deriveTitle(source: AgentSessionSource, text: string): string | undefined { const request = source === "codex" ? text.replace(CODEX_TITLE_PREAMBLE, "") : text; return request.trim().split("\n")[0]?.slice(0, 100).trim() || undefined; @@ -1000,6 +1022,29 @@ export const make = Effect.gen(function* () { ); }); + const readCodexThreadNames = Effect.fn("AgentSessionScanner.readCodexThreadNames")(function* ( + homePath: string, + ) { + const indexPath = path.join(homePath, "session_index.jsonl"); + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(indexPath, { flag: "r" }); + const size = Number((yield* file.stat).size); + const start = Math.max(0, size - MAX_CODEX_SESSION_INDEX_BYTES); + yield* file.seek(BigInt(start), "start"); + const bytes = yield* file.readAlloc(size - start); + const contents = Option.match(bytes, { + onNone: () => "", + onSome: (value) => new TextDecoder().decode(value), + }); + // A tail read can start mid-record. + return parseCodexThreadNames( + start > 0 ? contents.slice(contents.indexOf("\n") + 1) : contents, + ); + }), + ).pipe(Effect.orElseSucceed((): ReadonlyMap => new Map())); + }); + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { const configured = homePath.trim(); if (configured.length > 0) { @@ -1056,6 +1101,7 @@ export const make = Effect.gen(function* () { filePath, mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, + homePath, size: Number(stats.value.size), }); } @@ -1117,6 +1163,7 @@ export const make = Effect.gen(function* () { filePath, mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, + homePath, size: Number(stats.value.size), }); } @@ -1173,6 +1220,7 @@ export const make = Effect.gen(function* () { filePath, mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, + homePath: sessionsDir, size: Number(stats.value.size), }); } @@ -1191,6 +1239,7 @@ export const make = Effect.gen(function* () { { cwd: string; providerInstanceId: ProviderInstanceId; + homePath: string; lastActiveAtMs: number; transcripts: Array<{ filePath: string; mtimeMs: number }>; } @@ -1208,6 +1257,7 @@ export const make = Effect.gen(function* () { byOwnerAndCwd.set(key, { cwd, providerInstanceId: transcript.providerInstanceId, + homePath: transcript.homePath, lastActiveAtMs: transcript.mtimeMs, transcripts: [transcript], }); @@ -1218,6 +1268,7 @@ export const make = Effect.gen(function* () { cwd: group.cwd, source, providerInstanceId: group.providerInstanceId, + homePath: group.homePath, threadCount: group.transcripts.length, lastActiveAtMs: group.lastActiveAtMs, transcripts: group.transcripts, @@ -1520,6 +1571,7 @@ export const make = Effect.gen(function* () { (source) => `${source.providerInstanceId}\0${source.filePath}`, ); const importedSessions = new Set(); + const codexThreadNamesByHome = new Map>(); let bytesRemaining = MAX_IMPORT_BYTES; let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; let recordsRemaining = MAX_IMPORT_RECORDS; @@ -1598,6 +1650,14 @@ export const make = Effect.gen(function* () { if (parsedThread === null) { return Option.some({ _tag: "Skipped" }); } + let savedTitle: string | undefined; + if (candidate.source === "codex") { + const names = + codexThreadNamesByHome.get(candidate.homePath) ?? + (yield* readCodexThreadNames(candidate.homePath)); + codexThreadNamesByHome.set(candidate.homePath, names); + savedTitle = names.get(parsedThread.providerSessionId); + } const source: AgentSessionImportSource = { ...identity, @@ -1612,7 +1672,8 @@ export const make = Effect.gen(function* () { importedSessions.add(sessionKey); return Option.some({ _tag: "Importable", - thread: parsedThread, + thread: + savedTitle === undefined ? parsedThread : { ...parsedThread, title: savedTitle }, source, }); }).pipe(importReadLock.withPermits(1)), diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 8cf70f302b8f2..550738b24baae 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -42,6 +42,8 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu ? {} : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), + ...(mutation.pinned === undefined ? {} : { pinned: mutation.pinned }), + ...(mutation.archived === undefined ? {} : { archived: mutation.archived }), }); case "project.delete": diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 2738cc37bc26d..71c0e7c517f7f 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -178,6 +178,8 @@ export const make = Effect.gen(function* () { autoPull: row.autoPull, projectIcon: row.projectIcon, scripts: row.scripts, + pinnedAt: row.pinnedAt, + archivedAt: row.archivedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -386,6 +388,8 @@ export const make = Effect.gen(function* () { ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + ...(input.pinned === undefined ? {} : { pinned: input.pinned }), + ...(input.archived === undefined ? {} : { archived: input.archived }), }); if (workspaceRoot !== previousRoot) { yield* projectEnrichment.invalidate([previousRoot, workspaceRoot]); diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index d401fde52c2d7..79eaaa60a11ce 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -15,12 +15,10 @@ const BILLED_USAGE_FIELDS = [ ] as const; function addKnownCount(finalCount: unknown, earlierCounts: ReadonlyArray): unknown { - const known = finiteNonNegativeInteger(finalCount); - if (known === undefined) return finalCount; - return earlierCounts.reduce( - (sum, count) => sum + (finiteNonNegativeInteger(count) ?? 0), - known, - ); + const counts = [finalCount, ...earlierCounts] + .map(finiteNonNegativeInteger) + .filter((count) => count !== undefined); + return counts.length === 0 ? finalCount : counts.reduce((sum, count) => sum + count, 0); } // Steering aborts the current segment; each SDK result reports only that segment's usage. @@ -38,18 +36,16 @@ function withEarlierSegmentUsage( ); } const outputDetails = usage.output_tokens_details as Record | undefined; - if (outputDetails != null) { - total.output_tokens_details = { - ...outputDetails, - thinking_tokens: addKnownCount( - outputDetails.thinking_tokens, - segments.map( - (segment) => - (segment.output_tokens_details as Record | undefined)?.thinking_tokens, - ), + total.output_tokens_details = { + ...outputDetails, + thinking_tokens: addKnownCount( + outputDetails?.thinking_tokens, + segments.map( + (segment) => + (segment.output_tokens_details as Record | undefined)?.thinking_tokens, ), - }; - } + ), + }; return total; } @@ -60,15 +56,7 @@ export function normalizeClaudeTurnTokenUsage( earlierSegmentUsage: ReadonlyArray = [], ): TurnTokenUsage { const finalUsage = result?.usage as Record | undefined; - const usage = finalUsage && withEarlierSegmentUsage(finalUsage, earlierSegmentUsage); - if (!usage) { - return { - usageStatus: "unavailable", - usageScope: "main_agent", - hasSubagents, - }; - } - + const usage = withEarlierSegmentUsage(finalUsage ?? {}, earlierSegmentUsage); const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens); const cachedInputTokens = finiteNonNegativeInteger(usage.cache_read_input_tokens); const cacheCreationTokens = finiteNonNegativeInteger(usage.cache_creation_input_tokens); @@ -116,8 +104,14 @@ export function normalizeClaudeTurnTokenUsage( if ( terminalStatus === "completed" && result?.subtype === "success" && + finiteNonNegativeInteger(finalUsage?.input_tokens) !== undefined && + finiteNonNegativeInteger(finalUsage?.output_tokens) !== undefined && inputTokens !== undefined && - rawOutputTokens !== undefined + rawOutputTokens !== undefined && + BILLED_USAGE_FIELDS.every( + (field) => + finalUsage?.[field] == null || finiteNonNegativeInteger(finalUsage[field]) !== undefined, + ) ) { return { ...commonUsage, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 28a4a649d53bd..1fc875567c94e 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -127,12 +127,17 @@ function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } -function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { +function codexAccountAuthLabel( + account: CodexSchema.V2GetAccountResponse["account"], + usagePlanType: string | null | undefined, +) { if (!account) return undefined; if (account.type === "apiKey") return "OpenAI API Key"; if (account.type === "amazonBedrock") return "Amazon Bedrock"; if (account.type !== "chatgpt") return undefined; - return codexPlanLabel(account.planType); + // account/read takes the plan from the cached login token; the usage read asks the backend. + const usageLabel = usagePlanType === "unknown" ? undefined : codexPlanLabel(usagePlanType); + return usageLabel ?? codexPlanLabel(account.planType); } export function codexPlanLabel(planType: string | null | undefined): string | undefined { @@ -717,7 +722,10 @@ function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnaps readonly auth: ServerProvider["auth"]; readonly message?: string; } { - const authLabel = codexAccountAuthLabel(account.account); + const authLabel = codexAccountAuthLabel( + account.account, + rateLimits && "snapshot" in rateLimits ? rateLimits.snapshot.planType : undefined, + ); const authEmail = codexAccountEmail(account.account); const workspaceId = rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index a0efa733642e8..0134a0a4a6653 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -377,6 +377,28 @@ it.layer( }), ); + it.effect("labels the plan the usage read reports over the cached login plan", () => + Effect.gen(function* () { + const statusWithUsagePlan = (planType: string | null) => + checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + rateLimits: { snapshot: { planType }, resetCredits: null }, + }), + ), + ); + + const downgraded = yield* statusWithUsagePlan("free"); + assert.strictEqual(downgraded.auth.label, "ChatGPT Free Subscription"); + + const unreported = yield* statusWithUsagePlan(null); + assert.strictEqual(unreported.auth.label, "ChatGPT Pro 200 Subscription"); + + const unknown = yield* statusWithUsagePlan("unknown"); + assert.strictEqual(unknown.auth.label, "ChatGPT Pro 200 Subscription"); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => diff --git a/apps/server/src/provider/TurnTokenUsage.test.ts b/apps/server/src/provider/TurnTokenUsage.test.ts index 25db70f6411a6..06802143e73c1 100644 --- a/apps/server/src/provider/TurnTokenUsage.test.ts +++ b/apps/server/src/provider/TurnTokenUsage.test.ts @@ -130,4 +130,93 @@ describe("billable native turn usage", () => { ), ).toMatchObject({ usageStatus: "partial", inputTokens: 4, outputTokens: 2 }); }); + + it.each(["failed", "interrupted", "completed"] as const)( + "retains measured Claude steering usage as partial after %s without final usage", + (status) => { + expect( + normalizeClaudeTurnTokenUsage( + status === "completed" ? { subtype: "success" } : undefined, + true, + status, + [ + { input_tokens: 100, cache_read_input_tokens: 40, output_tokens: 20 }, + { input_tokens: 5, cache_read_input_tokens: 2, output_tokens: 3 }, + ], + ), + ).toEqual({ + usageStatus: "partial", + usageScope: "main_agent", + hasSubagents: true, + inputTokens: 147, + cachedInputTokens: 42, + outputTokens: 23, + }); + }, + ); + + it.each([undefined, null, {}])( + "keeps unavailable Claude usage unavailable without measured segments (%s)", + (usage) => { + expect( + normalizeClaudeTurnTokenUsage({ subtype: "success", usage }, false, "completed") + .usageStatus, + ).toBe("unavailable"); + }, + ); + + it("retains measured thinking tokens without a final result", () => { + expect( + normalizeClaudeTurnTokenUsage(undefined, false, "failed", [ + { input_tokens: 4, output_tokens: 3, output_tokens_details: { thinking_tokens: 2 } }, + ]), + ).toMatchObject({ + usageStatus: "partial", + inputTokens: 4, + outputTokens: 3, + reasoningTokens: 2, + }); + }); + + it.each([undefined, null])( + "retains earlier cache counts when a steering segment reports %s", + (cache) => { + expect( + normalizeClaudeTurnTokenUsage(undefined, false, "failed", [ + { input_tokens: 100, cache_read_input_tokens: 40, output_tokens: 20 }, + { input_tokens: 5, cache_read_input_tokens: cache, output_tokens: 3 }, + ]), + ).toMatchObject({ + usageStatus: "partial", + inputTokens: 145, + cachedInputTokens: 40, + outputTokens: 23, + }); + }, + ); + + it.each(["cache_read_input_tokens", "cache_creation_input_tokens"])( + "keeps Claude usage partial when the final %s is invalid", + (field) => { + expect( + normalizeClaudeTurnTokenUsage( + { subtype: "success", usage: { input_tokens: 5, output_tokens: 3, [field]: -1 } }, + false, + "completed", + [{ input_tokens: 100, output_tokens: 20, [field]: 40 }], + ), + ).toMatchObject({ usageStatus: "partial", inputTokens: 145, outputTokens: 23 }); + }, + ); + + it("adds Claude steering segments to the final measured result once", () => { + expect( + normalizeClaudeTurnTokenUsage( + { subtype: "success", usage: { input_tokens: 5, output_tokens: 3 } }, + false, + "completed", + [{ input_tokens: 4, output_tokens: 2 }], + ), + ).toMatchObject({ usageStatus: "complete", inputTokens: 9, outputTokens: 5 }); + }); }); diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 4bc1b2c63388e..4de34a1f52615 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -9,7 +9,7 @@ import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.effect( - "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links", + "finds active and archived threads for exactly one pull request, excluding deleted, dismissed, and side chat links", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -39,6 +39,13 @@ it.effect( source: "created", }, { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "side-chat", + host: "github.com", + repository: "acme/web", + number: 7, + source: "manual", + }, { id: "dismissed", host: "github.com", @@ -71,6 +78,7 @@ it.effect( for (const fixture of fixtures) { const payload = yield* encodePayload({ linkedPullRequest: null, + ...(fixture.id === "side-chat" ? { lineage: { sideChat: true } } : {}), pullRequests: [{ ...fixture, linkedAt: createdAt, snapshot: null, stack: null }], }); yield* sql` diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index c7a2b01946a2b..b140714531ad9 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -39,6 +39,7 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre WHERE json_extract(link.value, '$.number') = ${key.number} AND json_extract(link.value, '$.source') != 'stack-dismissed' AND t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 ORDER BY "updatedAt" DESC, id ASC `.pipe(NodeSqliteClient.readOnly); const threads = rows diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index c82f5f3cad077..2b76ccb09ba9b 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -10,6 +10,8 @@ import { fakeDiscovery, fakeSourceControlProvider, } from "@t3tools/source-control-testing/fakeSourceControlDriver"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as ServerConfig from "../config.ts"; @@ -57,31 +59,32 @@ const cliDiscovery = (name: string) => ({ providerAuth({ status: "authenticated", account: /as (\S+)/.exec(stdout)?.[1] }), }); -const layerDiscovery = Layer.effect( - SourceControlProviderRegistry.SourceControlProviderRegistry, - SourceControlProviderRegistry.makeWithProviders( - [ - cliDiscovery("alpha"), - cliDiscovery("beta"), - fakeDiscovery("gamma", { - probeAuth: Effect.succeed(providerAuth({ status: "authenticated", account: "api-user" })), - }), - ].map((discovery) => ({ - kind: discovery.kind, - provider: fakeSourceControlProvider(discovery.kind), - discovery, - })), - ), -).pipe( - Layer.provideMerge(TestSourceControlHost.layer({ process: { run } })), - Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), - Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), - Layer.provideMerge(Layer.mock(VcsProcess.VcsProcess)({ run })), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), - ), - Layer.provideMerge(NodeServices.layer), -); +const layerDiscovery = (runProcess = run) => + Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + SourceControlProviderRegistry.makeWithProviders( + [ + cliDiscovery("alpha"), + cliDiscovery("beta"), + fakeDiscovery("gamma", { + probeAuth: Effect.succeed(providerAuth({ status: "authenticated", account: "api-user" })), + }), + ].map((discovery) => ({ + kind: discovery.kind, + provider: fakeSourceControlProvider(discovery.kind), + discovery, + })), + ), + ).pipe( + Layer.provideMerge(TestSourceControlHost.layer({ process: { run: runProcess } })), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), + Layer.provideMerge(Layer.mock(VcsProcess.VcsProcess)({ run: runProcess })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provideMerge(NodeServices.layer), + ); it.effect("reports every hosting integration in registry order with its sign-in", () => Effect.gen(function* () { @@ -127,5 +130,44 @@ it.effect("reports every hosting integration in registry order with its sign-in" ["jj", "available"], ], ); - }).pipe(Effect.provide(SourceControlDiscovery.layer.pipe(Layer.provide(layerDiscovery)))), + }).pipe(Effect.provide(SourceControlDiscovery.layer.pipe(Layer.provide(layerDiscovery())))), +); + +it.effect.each(["darwin", "linux"] as const)( + "names incompatible executables only on macOS (%s)", + (platform) => { + const runProcess: typeof run = (input) => + Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: + input.command === "missing-cli" + ? new Error("not found") + : Object.assign(new Error("spawn Unknown system error -86"), { errno: -86 }), + }), + ); + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + for (const item of [...result.versionControlSystems, ...result.sourceControlProviders]) { + if (item.kind === kind("gamma")) continue; + assert.strictEqual(item.status, "missing"); + if (platform === "darwin" && item.executable !== "missing-cli") { + assert.include( + item.installHint, + `\`/usr/local/bin/${item.executable}\` is built for another CPU architecture, so this arm64 machine cannot run it. Remove or replace it.`, + ); + } else { + assert.notInclude(item.installHint, "CPU architecture"); + } + } + }).pipe( + Effect.provide(SourceControlDiscovery.layer.pipe(Layer.provide(layerDiscovery(runProcess)))), + Effect.provideService(SpawnExecutableResolution, (command) => `/usr/local/bin/${command}`), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Architecture, "arm64"), + ); + }, ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index a22b83baaf2f4..0657e90de8a02 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,7 +10,11 @@ import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { detailFromCause, firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; +import { + detailFromCause, + firstNonEmptyLine, + unavailableInstallHint, +} from "@t3tools/source-control-core/server/discovery"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { @@ -103,16 +107,19 @@ export const make = Effect.gen(function* () { }) satisfies DiscoveryProbeResult, ), Effect.catch((cause) => - Effect.succeed({ - kind: input.kind, - label: input.label, - executable, - implemented: input.implemented, - status: "missing" as const, - version: Option.none(), - installHint: input.installHint, - detail: detailFromCause(cause), - } satisfies DiscoveryProbeResult), + Effect.gen(function* () { + const installHint = yield* unavailableInstallHint(executable, input.installHint, cause); + return { + kind: input.kind, + label: input.label, + executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult; + }), ), ); }; diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index a22223b5410c7..ca8b81f188fba 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -111,14 +111,16 @@ describe("V2 storage cleanup eligibility", () => { const candidate = () => shell({ branch: "feature", worktreePath: "/worktrees/feature" }); it("allows an idle worktree and rejects the project checkout", () => { - expect(storageCleanupThreadIdle(candidate(), NOW_MS)).toBe(true); - expect(storageCleanupThreadIdle(shell(), NOW_MS)).toBe(false); + expect(storageCleanupThreadIdle(candidate(), NOW_MS, DEFAULT_SERVER_SETTINGS)).toBe(true); + expect(storageCleanupThreadIdle(shell(), NOW_MS, DEFAULT_SERVER_SETTINGS)).toBe(false); }); it.each(["running", "starting", "preparing", "waiting", "queued"] as const)( "retains a worktree while its thread is %s", (status) => { - expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(false); + expect( + storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS, DEFAULT_SERVER_SETTINGS), + ).toBe(false); }, ); @@ -132,6 +134,7 @@ describe("V2 storage cleanup eligibility", () => { pendingBackgroundTasks: [{ taskId: "task-1", kind: "command" }], }, NOW_MS, + DEFAULT_SERVER_SETTINGS, ), ).toBe(false); }, @@ -151,6 +154,7 @@ describe("V2 storage cleanup eligibility", () => { }, }, NOW_MS, + DEFAULT_SERVER_SETTINGS, ), ).toBe(false); }, @@ -158,16 +162,83 @@ describe("V2 storage cleanup eligibility", () => { it("retains an active run even if the shell status is idle", () => { expect( - storageCleanupThreadIdle({ ...candidate(), activeRunId: RunId.make("run") }, NOW_MS), + storageCleanupThreadIdle( + { ...candidate(), activeRunId: RunId.make("run") }, + NOW_MS, + DEFAULT_SERVER_SETTINGS, + ), ).toBe(false); }); it("retains a queued prompt before the new run has been projected", () => { expect( - storageCleanupThreadIdle({ ...candidate(), latestUserMessageAt: at(-1_000) }, NOW_MS), + storageCleanupThreadIdle( + { ...candidate(), latestUserMessageAt: at(-1_000) }, + NOW_MS, + DEFAULT_SERVER_SETTINGS, + ), ).toBe(false); }); + it.each([ + { name: "snoozed until a future reset", snooze: true, resetIn: DAY_MS, idle: false }, + { name: "set to auto-resume", autoResume: true, resetIn: DAY_MS, idle: false }, + { name: "with recovery turned off", resetIn: DAY_MS, idle: true }, + { name: "auto-resuming after the reset", autoResume: true, resetIn: -1_000, idle: false }, + { name: "snoozed past its reset", snooze: true, resetIn: -1_000, idle: true }, + { + name: "settled with an auto-resume pending", + autoResume: true, + settled: true, + resetIn: -1_000, + idle: true, + }, + { name: "not yet armed by the recovery worker", armed: false, resetIn: DAY_MS, idle: false }, + { + name: "not yet armed, auto-resume after reset", + armed: false, + autoResumeSetting: true, + resetIn: -1_000, + idle: false, + }, + { + name: "not armed with recovery settings off", + armed: false, + settingsOff: true, + resetIn: DAY_MS, + idle: true, + }, + ])( + "treats a usage-limited thread $name as idle: $idle", + ({ + autoResume = false, + snooze = false, + armed = true, + autoResumeSetting = false, + settled, + settingsOff, + resetIn, + idle, + }) => { + const runId = RunId.make("run-1"); + const resetAt = DateTime.formatIso(at(resetIn)); + expect( + storageCleanupThreadIdle( + { + ...candidateWithStatus("failed"), + latestRunId: runId, + lastErrorClass: "usage_limit", + usageLimitResetAt: resetAt, + settledOverride: settled ? "settled" : null, + limitRecovery: armed ? { runId, resetAt, autoResume, snooze } : null, + }, + NOW_MS, + { autoResumeLimitedThreads: autoResumeSetting, snoozeLimitedThreads: !settingsOff }, + ), + ).toBe(idle); + }, + ); + it("uses V2 run activity instead of metadata refreshes for retention", () => { const thread = candidate(); const runTime = at(-3 * DAY_MS); @@ -729,6 +800,7 @@ describe("subagent worktree ownership", () => { "pending", "unrelated", "fork", + "side-chat", "recent-activity", "recent-inactive", "recent-grace", @@ -782,7 +854,12 @@ describe("subagent worktree ownership", () => { rootThreadId: scenario === "wrong-root" ? ThreadId.make("other-root") : root.lineage.rootThreadId, relationshipToParent: - scenario === "fork" ? "fork" : scenario === "unrelated" ? null : "subagent", + scenario === "fork" || scenario === "side-chat" + ? "fork" + : scenario === "unrelated" + ? null + : "subagent", + ...(scenario === "side-chat" ? { sideChat: true } : {}), }, status: scenario === "running" ? "running" : "completed", pendingBackgroundTasks: scenario === "pending" ? [{ taskId: "task", kind: "command" }] : [], @@ -816,7 +893,9 @@ describe("subagent worktree ownership", () => { root, ]); const report = yield* fixture.service.runNow; - const removed = ["idle", "grandchild", "isolated-owner", "fork-owner"].includes(scenario); + const removed = ["idle", "grandchild", "isolated-owner", "fork-owner", "side-chat"].includes( + scenario, + ); const shared = [ "unrelated", "fork", @@ -2286,6 +2365,33 @@ describe("storage cleanup reports and local file policies", () => { }); describe("settled worktree rechecks", () => { + it.live.each(["autoResumeLimitedThreads", "snoozeLimitedThreads"] as const)( + "keeps a usage-limited checkout when %s is enabled during measurement", + (preference) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* pausedMeasurementFixture(); + fixture.setSettings({ autoResumeLimitedThreads: false, snoozeLimitedThreads: false }); + fixture.setThreads([ + shell({ + branch: "feature", + worktreePath: fixture.worktree, + status: "failed", + latestRunId: RunId.make("limited-run"), + lastErrorClass: "usage_limit", + usageLimitResetAt: DateTime.formatIso(at(DAY_MS)), + }), + ]); + const run = yield* fixture.service.runNow.pipe(Effect.forkChild); + yield* Deferred.await(fixture.measuring); + fixture.setSettings({ [preference]: true }); + yield* Deferred.succeed(fixture.resume, undefined); + const report = yield* Fiber.join(run); + expect(report.entries[0]?.outcome).toBe("kept"); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }), + ), + ); it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( "keeps a checkout changed during measurement (%j)", (change) => diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 40ee347343095..95a8a2f7b8c74 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -115,7 +115,16 @@ function sameProjectWorktreePolicies(left: ServerSettings, right: ServerSettings ); } -export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now: number): boolean { +type LimitRecoverySettings = Pick< + ServerSettings, + "autoResumeLimitedThreads" | "snoozeLimitedThreads" +>; + +export function storageCleanupThreadIdle( + thread: OrchestrationV2ThreadShell, + now: number, + settings: LimitRecoverySettings, +): boolean { return ( thread.worktreePath !== null && thread.activeRunId === null && @@ -127,10 +136,41 @@ export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now thread.status === "rolled_back") && (thread.pendingBackgroundTasks?.length ?? 0) === 0 && thread.pendingRuntimeRequest === null && - !threadHasQueuedTurnStart(thread, now) + !threadHasQueuedTurnStart(thread, now) && + !heldForLimitRecovery(thread, now, settings) ); } +// A run held for its usage-limit reset has its work still ahead of it, and a +// fresh worktree would otherwise look unchanged and be removed before it runs. +function heldForLimitRecovery( + thread: OrchestrationV2ThreadShell, + now: number, + settings: LimitRecoverySettings, +): boolean { + if ( + thread.status !== "failed" || + thread.lastErrorClass !== "usage_limit" || + !thread.usageLimitResetAt || + thread.archivedAt !== null || + thread.settledOverride === "settled" + ) + return false; + const resetMs = Date.parse(thread.usageLimitResetAt); + const recovery = thread.limitRecovery; + if (recovery?.runId === thread.latestRunId && recovery.resetAt === thread.usageLimitResetAt) + // An auto-resume holds until its continuation replaces the failed run. + return recovery.autoResume || (recovery.snooze === true && resetMs > now); + // The recovery worker arms this failure on a later tick; hold it until then. + // Like the worker, skip a reset that had already passed when the run ended. + if ( + !Number.isFinite(resetMs) || + resetMs <= DateTime.toEpochMillis(thread.latestRunCompletedAt ?? thread.updatedAt) + ) + return false; + return settings.autoResumeLimitedThreads || (settings.snoozeLimitedThreads && resetMs > now); +} + /** PR metadata refreshes must not reset the inactivity clock. */ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): number { return Math.max( @@ -144,7 +184,7 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu ); } -/** Only a complete subagent chain within this worktree shares ownership. */ +/** Only a complete subagent or side chat chain within this worktree shares ownership. */ function worktreeOwner>( threads: ReadonlyArray, ) { @@ -159,7 +199,8 @@ function worktreeOwner storageCleanupThreadIdle(member, now)) + !group.every((member) => storageCleanupThreadIdle(member, now, serverSettings)) ) return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) @@ -968,7 +1009,7 @@ const make = Effect.gen(function* () { shell.branch !== thread.branch || !Equal.equals(shell.settledAt, previous.settledAt) || (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) || - !storageCleanupThreadIdle(shell, now) || + !storageCleanupThreadIdle(shell, now, serverSettings) || path.resolve(shell.worktreePath!) !== worktreePath || storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous) ); @@ -1008,11 +1049,11 @@ const make = Effect.gen(function* () { return keep("commit changed since check"); const finalChanges = yield* localChangesReason(worktreePath, settings); if (finalChanges !== null) return keep(finalChanges); - const current = resolveWorktreeCleanup( - yield* settingsService.getSettings, - thread.projectId, - ); + const currentSettings = yield* settingsService.getSettings; + const current = resolveWorktreeCleanup(currentSettings, thread.projectId); if ( + currentSettings.autoResumeLimitedThreads !== serverSettings.autoResumeLimitedThreads || + currentSettings.snoozeLimitedThreads !== serverSettings.snoozeLimitedThreads || Object.keys(settings).some( (key) => current[key as keyof typeof settings] !== settings[key as keyof typeof settings], diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b6b8f44283d18..8183f574a56b7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4842,6 +4842,137 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([ + { location: "root", ref: "refs/heads/MERGE_HEAD" }, + { location: "nested", ref: "refs/heads/MERGE_HEAD" }, + { location: "worktree", ref: "refs/heads/MERGE_HEAD" }, + { location: "root", ref: "refs/tags/MERGE_HEAD" }, + { location: "nested", ref: "refs/tags/MERGE_HEAD" }, + { location: "worktree", ref: "refs/tags/MERGE_HEAD" }, + ])( + "commits selected files with a MERGE_HEAD ref and no merge ($location, $ref)", + ({ location, ref }) => + Effect.gen(function* () { + let cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const originalHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["update-ref", ref, originalHead]); + if (location === "worktree") { + const linked = path.join(yield* makeTmpDir(), "linked"); + yield* git(cwd, ["worktree", "add", "-b", "linked", linked]); + cwd = linked; + } + const commitCwd = location === "nested" ? path.join(cwd, "nested") : cwd; + yield* fs.makeDirectory(commitCwd, { recursive: true }); + yield* writeTextFile(cwd, "selected.txt", "selected\n"); + yield* writeTextFile(cwd, "unrelated.txt", "unrelated\n"); + const mergePath = yield* git(commitCwd, ["rev-parse", "--git-path", "MERGE_HEAD"]); + assert.isFalse(yield* fs.exists(path.resolve(commitCwd, mergePath))); + + yield* driver.commit(commitCwd, "Selected without merge", "", { + stage: { filePaths: ["selected.txt"] }, + }); + + assert.equal(yield* git(cwd, ["show", "-s", "--format=%P", "HEAD"]), originalHead); + assert.equal( + yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), + "selected.txt", + ); + assert.equal(yield* git(cwd, ["rev-parse", ref]), originalHead); + assert.include(yield* git(cwd, ["status", "--porcelain"]), "?? unrelated.txt"); + }), + ); + + it.effect.each([ + { location: "root", conflict: false }, + { location: "nested", conflict: false }, + { location: "worktree", conflict: false }, + { location: "root", conflict: true }, + { location: "nested", conflict: true }, + { location: "worktree", conflict: true }, + ])( + "rejects selected-file merge commits without mutation ($location, conflict: $conflict)", + ({ location, conflict }) => + Effect.gen(function* () { + let cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + if (conflict) yield* writeTextFile(cwd, "README.md", "feature version\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Feature"]); + const featureHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "main.txt", "main\n"); + if (conflict) yield* writeTextFile(cwd, "README.md", "main version\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Main"]); + const mainHead = yield* git(cwd, ["rev-parse", "HEAD"]); + if (location === "worktree") { + const worktree = path.join(yield* makeTmpDir(), "linked"); + yield* git(cwd, ["worktree", "add", "-b", "linked", worktree]); + cwd = worktree; + } + const merge = yield* driver.execute({ + operation: "test.merge", + cwd, + args: ["merge", "--no-commit", "--no-ff", "feature"], + allowNonZeroExit: true, + }); + assert.equal(merge.exitCode, conflict ? 1 : 0); + yield* writeTextFile(cwd, "unrelated.txt", "unrelated\n"); + const commitCwd = location === "nested" ? path.join(cwd, "nested") : cwd; + yield* fs.makeDirectory(commitCwd, { recursive: true }); + const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); + const names = ["index", "MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD"]; + const before = names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))); + const files = ["README.md", "feature.txt", "main.txt", "unrelated.txt"]; + const contents = files.map((name) => NodeFS.readFileSync(path.join(cwd, name))); + const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); + + const result = yield* driver + .commit(commitCwd, "Partial merge", "", { stage: { filePaths: ["feature.txt"] } }) + .pipe(Effect.result); + if (Result.isSuccess(result)) { + const parents = yield* git(cwd, ["show", "-s", "--format=%P", "HEAD"]); + assert.fail( + `Selected merge commit succeeded with parents ${parents}; MERGE_HEAD exists: ${NodeFS.existsSync(path.join(gitDir, "MERGE_HEAD"))}`, + ); + } else { + assert.include(result.failure.detail, "Cannot commit selected files during a merge"); + assert.include(result.failure.detail, "Commit all files"); + } + assert.deepEqual( + names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))), + before, + ); + assert.deepEqual( + files.map((name) => NodeFS.readFileSync(path.join(cwd, name))), + contents, + ); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), mainHead); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + + if (conflict) yield* writeTextFile(cwd, "README.md", "resolved version\n"); + yield* driver.commit(commitCwd, "Finish merge", "", { stage: {} }); + assert.equal( + yield* git(cwd, ["show", "-s", "--format=%P", "HEAD"]), + `${mainHead} ${featureHead}`, + ); + assert.isFalse(NodeFS.existsSync(path.join(gitDir, "MERGE_HEAD"))); + assert.equal(yield* git(cwd, ["show", "HEAD:feature.txt"]), "feature"); + assert.equal(yield* git(cwd, ["show", "HEAD:unrelated.txt"]), "unrelated"); + if (conflict) + assert.equal(yield* git(cwd, ["show", "HEAD:README.md"]), "resolved version"); + }), + ); + it.effect("treats selected file paths literally", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4869,6 +5000,56 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + const commitWithHooks = (hooks: Record) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [name, script] of Object.entries(hooks)) { + yield* writeTextFile(cwd, `.git/hooks/${name}`, `#!/bin/sh\n${script}\n`); + yield* fs.chmod(path.join(cwd, ".git/hooks", name), 0o755); + } + yield* writeTextFile(cwd, "a.txt", "a\n"); + const events: string[] = []; + const result = yield* (yield* GitVcsDriver.GitVcsDriver) + .commit(cwd, "Add a", "", { + stage: { filePaths: ["a.txt"] }, + progress: { + onHookStarted: (hookName) => Effect.sync(() => void events.push(`start ${hookName}`)), + onHookFinished: ({ hookName, exitCode }) => + Effect.sync(() => void events.push(`finish ${hookName} ${exitCode}`)), + }, + }) + .pipe(Effect.result); + return { events, committed: Result.isSuccess(result) }; + }); + + it.effect("reports each commit hook finishing with its exit code", () => + Effect.gen(function* () { + // The pre-commit hook runs git, whose own children reuse child_id 0 in the shared trace. + const { events, committed } = yield* commitWithHooks({ + "pre-commit": "git -c alias.noop='!false' noop\nexit 0", + "commit-msg": "exit 0", + }); + assert.isTrue(committed); + assert.deepEqual(events, [ + "start pre-commit", + "finish pre-commit 0", + "start commit-msg", + "finish commit-msg 0", + ]); + }), + ); + + it.effect("reports the exit code of a commit hook that rejects the commit", () => + Effect.gen(function* () { + const { events, committed } = yield* commitWithHooks({ "commit-msg": "exit 3" }); + assert.isFalse(committed); + assert.deepEqual(events, ["start commit-msg", "finish commit-msg 3"]); + }), + ); + it.effect.each([false, true])( "preserves partial staging and split-index files (split: %s)", (split) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2987a6d983336..27e9b6247d4f1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -51,6 +51,7 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { readProjectConfig } from "../project/configMetadata.ts"; +import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts"; import * as ServerConfig from "../config.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; @@ -628,10 +629,11 @@ interface Trace2Monitor { readonly flush: Effect.Effect; } +// Nested Git processes share the trace file but reuse child IDs in their own sessions. function trace2ChildKey(record: Record): string | null { const childId = record.child_id; if (typeof childId === "number" || typeof childId === "string") { - return String(childId); + return typeof record.sid === "string" ? `${record.sid}\n${childId}` : String(childId); } const hookName = record.hook_name; return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; @@ -683,10 +685,6 @@ const createTrace2Monitor = Effect.fnUntraced(function* ( return; } - if (traceRecord.success.child_class !== "hook") { - return; - } - const event = traceRecord.success.event; const childKey = trace2ChildKey(traceRecord.success); if (childKey === null) { @@ -701,6 +699,9 @@ const createTrace2Monitor = Effect.fnUntraced(function* ( } if (event === "child_start") { + if (traceRecord.success.child_class !== "hook") { + return; + } const now = yield* DateTime.now; hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); if (progress.onHookStarted) { @@ -709,17 +710,15 @@ const createTrace2Monitor = Effect.fnUntraced(function* ( return; } - if (event === "child_exit") { + if (event === "child_exit" && started) { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.exitCode; + const code = traceRecord.success.code; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; const now = yield* DateTime.now; - const durationMs = started - ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs) - : null; + const durationMs = Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs); if (progress.onHookFinished) { yield* progress.onHookFinished({ - hookName: started?.hookName ?? hookName, + hookName: started.hookName, exitCode, durationMs, }); @@ -934,6 +933,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; + const isServerInternalPath = yield* makeIsServerInternalPath; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcess.Platform; const serviceScope = yield* Effect.scope; @@ -2381,6 +2381,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { env }, ); } else { + const mergeHeadPath = yield* runGitStdout("GitVcsDriver.commit.mergeHead", cwd, [ + "rev-parse", + "--git-path", + "MERGE_HEAD", + ]); + const isMerging = yield* fileSystem.exists(path.resolve(cwd, mergeHeadPath.trim())).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.commit.mergeHead", + command: "git commit", + cwd, + detail: "Could not check whether a merge is in progress.", + cause, + }), + ), + ); + // Like git commit --only, reject partial merges before reset can discard their ancestry. + if (isMerging) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit", + command: "git commit", + cwd, + detail: + "Cannot commit selected files during a merge. Commit all files to finish the merge.", + }); + } yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( Effect.catchTags({ GitCommandError: () => Effect.void }), ); @@ -3533,6 +3560,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `Diff file '${input.newPath}' resolves outside the review workspace.`, ); } + if (yield* isServerInternalPath(realTarget)) { + return yield* fileError("fs.realPath", `Diff file '${input.newPath}' is not available.`); + } const info = yield* fileSystem .stat(realTarget) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 37d85a0fc684e..0f252d600a5ba 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -45,7 +45,7 @@ const layerTest = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), - Layer.provide( + Layer.provideMerge( ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", }), @@ -206,6 +206,25 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("does not read server state files", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(config.secretsDir, "example.bin", "example\n"); + yield* writeTextFile(config.stateDir, "statev2.sqlite", "example\n"); + + for (const input of [ + { cwd, relativePath: path.join(config.secretsDir, "example.bin") }, + { cwd: config.stateDir, relativePath: "statev2.sqlite" }, + ]) { + const error = yield* workspaceFileSystem.readFile(input).pipe(Effect.flip); + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + } + }), + ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "rejects a FIFO without blocking on open", () => @@ -342,6 +361,26 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("drops a multibyte character split by the read limit", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const limit = 1024 * 1024; + const euro = new TextEncoder().encode("€"); + const bytes = new Uint8Array(limit - 1 + euro.length).fill(0x61); + bytes.set(euro, limit - 1); + yield* fileSystem.writeFile(path.join(cwd, "large.txt"), bytes); + + const result = yield* workspaceFileSystem.readFile({ cwd, relativePath: "large.txt" }); + + expect(result.truncated).toBe(true); + expect(result.byteLength).toBe(bytes.length); + expect(result.contents).toBe("a".repeat(limit - 1)); + }), + ); + it.effect("rejects binary files without leaking their contents into the error", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 053360572b312..e611d2bd71a3d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts"; import * as KnownWorkspaces from "./KnownWorkspaces.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -139,6 +140,7 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const knownWorkspaces = yield* KnownWorkspaces.KnownWorkspaces; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const isServerInternalPath = yield* makeIsServerInternalPath; const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); @@ -347,6 +349,12 @@ export const make = Effect.gen(function* () { }; const realTargetPath = target.realTargetPath; if (realTargetPath === null) return absentResult; + if (yield* isServerInternalPath(realTargetPath)) { + return yield* new WorkspacePaths.WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } return yield* Effect.acquireUseRelease( Effect.tryPromise({ @@ -418,11 +426,13 @@ export const make = Effect.gen(function* () { }); } + const truncated = stat.size > PROJECT_READ_FILE_MAX_BYTES; return { relativePath: target.relativePath, - contents: new TextDecoder("utf-8").decode(fileBytes), + // Streaming decode drops a character the byte limit split, instead of ending in U+FFFD. + contents: new TextDecoder("utf-8").decode(fileBytes, { stream: truncated }), byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, + truncated, }; }), (handle) => diff --git a/apps/web/package.json b/apps/web/package.json index 07a68d80bee5d..6e586303faaeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 0223dfb9d5bd5..3e2cacc57e736 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -54,6 +54,12 @@ describe("closedViewStore", () => { threadRef: refA, surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 }, }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "side-chat", id: "side-chat:x" }, + }, ])("discards incomplete saved views %j while retaining valid history", async (entry) => { const id = useClosedViewStore.getState().remember(diff(refA)); const { storage, name } = useClosedViewStore.persist.getOptions(); @@ -85,6 +91,11 @@ describe("closedViewStore", () => { it("removes private metadata and malformed entries from history saved by earlier versions", async () => { const store = useClosedViewStore.getState(); + const sideChatId = store.remember({ + kind: "panel-tab", + threadRef: refA, + surface: { id: "side-chat:side", kind: "side-chat", threadId: ThreadId.make("side") }, + }); const publicId = store.remember(diff(refA)); const browserId = store.remember({ kind: "browser", @@ -115,6 +126,7 @@ describe("closedViewStore", () => { expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ browserId, publicId, + sideChatId, ]); const saved = JSON.stringify(await storage!.getItem(name!)); expect(saved).not.toContain("private.example"); diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d08..b5db7bd741bef 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -83,6 +83,8 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { typeof surface.target.name === "string" && (surface.target.platform === "ios" || surface.target.platform === "android"))) ); + case "side-chat": + return typeof surface.threadId === "string" && surface.id === `side-chat:${surface.threadId}`; case "pull-request": return ( typeof surface.projectId === "string" && diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 82c280e6e56fe..3176c8132eed9 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,5 +1,6 @@ import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { isListedThreadLineage } from "@t3tools/contracts"; import type { AnyRouter } from "@tanstack/react-router"; import type { AtomRegistry } from "effect/reactivity"; @@ -42,9 +43,8 @@ export function startBackgroundThreadAnnouncements( const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { - // Subagents have no row of their own; their requests surface on the parent. - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - continue; + // Subagents and side chats have no row of their own; their requests surface on the parent. + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) continue; if (!liveEnvironmentIds.has(thread.environmentId)) continue; snapshots.push({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 8e01a43d19cf7..1b0dbcacfd286 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -198,9 +198,7 @@ const RunContextSelector = memo(function RunContextSelector({ const workspaceIcon = ( }> - + {isPanel @@ -218,9 +216,7 @@ const RunContextSelector = memo(function RunContextSelector({ kind={activeEnvironment?.machine ?? "server"} // Wide machine glyphs need a little more box to match the square row icons. className={ - isPanel - ? cn(THREAD_DETAILS_PANEL_ICON_CLASS, "-mx-px size-4.5") - : "size-3 shrink-0 mx-0!" + isPanel ? cn(THREAD_DETAILS_PANEL_ICON_CLASS, "-mx-px size-4.5") : "size-3 shrink-0" } /> @@ -243,7 +239,7 @@ const RunContextSelector = memo(function RunContextSelector({ return ( {showGitControls ? ( -
+
@@ -731,7 +727,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {activeWorktreePath ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 3e80e16443ad4..33faa9fdf510f 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -52,7 +52,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { const lockedRow = ( void) | undefined; + /** Keep one list per configuration (a module constant): it keys the shared parse cache. */ extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; /** Loads media a source control host keeps behind a credential through the server, which holds @@ -1421,6 +1422,18 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { mathBreaks: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS], } satisfies Record; +const combinedPlugins = new WeakMap>(); + +// One combined list per pair, so a stable extra list shares a renderer and its parse cache. +function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { + if (extra.length === 0) return shared; + let byShared = combinedPlugins.get(extra); + if (!byShared) combinedPlugins.set(extra, (byShared = new WeakMap())); + let combined = byShared.get(shared); + if (!combined) byShared.set(shared, (combined = [...shared, ...extra])); + return combined; +} + const markdownRenderers = new WeakMap< PluggableList, WeakMap> @@ -1509,15 +1522,11 @@ function ChatMarkdown({ ? "breaks" : "plain" ]; - // Extra and incremental plugins are per instance, so their output is not shared. - const uncachedPlugins = extraRemarkPlugins.length > 0 || incrementalParsing; - const remarkPlugins = uncachedPlugins - ? [ - ...sharedRemarkPlugins, - ...extraRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ] - : sharedRemarkPlugins; + // The incremental parser keeps per-instance state, so its output is not shared. + const uncachedPlugins = incrementalParsing; + const remarkPlugins = incrementalParsing + ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] + : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); const blocks = streamingBlocks ?? [text]; return ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 3c48d5d0a7e35..60016423d6ed7 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,5 +1,6 @@ import { boundAttachmentIds, + buildExpiredTerminalContextToastCopy, planComposerTurn, snapshotComposerSend, withMessageContext, @@ -124,6 +125,22 @@ describe("deriveComposerSendState", () => { }); }); +describe("buildExpiredTerminalContextToastCopy", () => { + it("refers to several expired contexts as them", () => { + expect(buildExpiredTerminalContextToastCopy(2, "empty")).toEqual({ + title: "Expired terminal contexts won't be sent", + description: "Remove them or re-add them to include terminal output.", + }); + expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ + title: "Expired terminal contexts omitted from message", + description: "Re-add them if you want that terminal output included.", + }); + expect(buildExpiredTerminalContextToastCopy(1, "omitted").description).toBe( + "Re-add it if you want that terminal output included.", + ); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("acknowledges a settled newer background turn", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 66a7869bc1799..df5256a176b3d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -702,15 +702,16 @@ export function buildExpiredTerminalContextToastCopy( ): { title: string; description: string } { const count = Math.max(1, Math.floor(expiredTerminalContextCount)); const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; + const pronoun = count === 1 ? "it" : "them"; if (variant === "empty") { return { title: `${noun} won't be sent`, - description: "Remove it or re-add it to include terminal output.", + description: `Remove ${pronoun} or re-add ${pronoun} to include terminal output.`, }; } return { title: `${noun} omitted from message`, - description: "Re-add it if you want that terminal output included.", + description: `Re-add ${pronoun} if you want that terminal output included.`, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 11d54873754b7..87abf50bf4bba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -56,6 +56,7 @@ import { type AssistantCitation, type ChatFileAttachment, isProviderNativeSubagentThread, + orchestrationV2WorktreePending, type EnvironmentId, type MessageId, type ModelSelection, @@ -157,10 +158,12 @@ import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; import { cn } from "~/lib/utils"; +import { isModelPickerOpen } from "../modelPickerVisibility"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; +import { isInSideChatPane, type SideChatParentPanel } from "~/lib/sideChatPane"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, @@ -194,7 +197,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ import { useEnvironmentQuery } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; -import { threadEnvironment, useThreadFamily } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironmentProviders, useEnvironments } from "../state/environments"; @@ -228,6 +231,7 @@ import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; +import { useSideChat } from "./chat/useSideChat"; import { PanelLayoutControls, type PanelLayoutControlsProps, @@ -339,6 +343,8 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + /** A side chat in its parent's right panel; the parent keeps the keyboard and workspace UI. */ + embedded?: SideChatParentPanel; routeKind: "server"; draftId?: never; } @@ -348,6 +354,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: never; routeKind: "draft"; draftId: DraftId; }; @@ -359,7 +366,9 @@ export default function ChatView(props: ChatViewProps) { routeKind, onDiffPanelOpen, forceExpandedMobileComposer = false, + embedded: parentPanel, } = props; + const embedded = parentPanel !== undefined; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const draftId = routeKind === "draft" ? props.draftId : null; @@ -419,14 +428,6 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; - // Subagent rows, an open child's own row and its ancestors come from the family when the shell - // leaves idle children out. Every source agrees on the root, so the key never flips. - useThreadFamily( - routeKind === "server" ? environmentId : null, - serverThreadShell?.lineage.rootThreadId ?? - serverProjection?.thread.lineage.rootThreadId ?? - null, - ); // Only the shell-less fallback needs these; with a shell, the shell carries them. const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); @@ -766,7 +767,29 @@ export default function ChatView(props: ChatViewProps) { : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); // The title it had at delegation, until this client sees the parent itself. const delegatedFromThread = useQuietThreadShell(delegatedFromRef); + const sideChatParentRef = + activeThread?.lineage.sideChat === true && activeThread.lineage.parentThreadId !== null + ? scopeThreadRef(activeThread.environmentId, activeThread.lineage.parentThreadId) + : null; + const sideChatParent = useQuietThreadShell(sideChatParentRef); const parentThreadLink = (() => { + if (sideChatParentRef !== null) { + return { + title: sideChatParent?.title ?? "Parent thread", + sideChat: true, + // Embedded, the parent is the view around it. + ...(embedded + ? {} + : { + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(sideChatParentRef), + }); + }, + }), + }; + } if (parentSubagentThreadRef !== null) { return { title: parentSubagentThread?.title ?? "Parent thread", @@ -951,7 +974,9 @@ export default function ChatView(props: ChatViewProps) { const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const canMaximizeRightPanel = rightPanelOpen; - const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; + // An embedded side chat has no panel; a stale maximized flag would hide its own column. + const rightPanelMaximized = + !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -1523,13 +1548,15 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const showComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - }); + const showComposerContextStrip = + !embedded && + shouldShowComposerContextStrip({ + isDraftHeroState, + persistInActiveThreads: settings.persistComposerContextStrip, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const terminalShortcutLabelOptions = { context: { terminalFocus: true, @@ -1595,7 +1622,8 @@ export default function ChatView(props: ChatViewProps) { const onInterruptStable = useStableCallback(onInterrupt); const scheduleComposerFocus = () => { window.requestAnimationFrame(() => { - focusComposer(); + // The model picker stays open through its own changes, so it keeps focus. + if (!isModelPickerOpen()) focusComposer(); }); }; const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); @@ -1738,8 +1766,8 @@ export default function ChatView(props: ChatViewProps) { : linkedThreadPullRequestKey; const activeRunningTurnId = activeRuntime?.activeRunId ?? null; useProactivePanels({ - threadRef: isServerThread ? activeThreadRef : null, - enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet, + threadRef: isServerThread && !embedded ? activeThreadRef : null, + enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet && !embedded, settingsHydrated: clientSettingsHydrated, threadDetailLoading, runningTurnId: activeRunningTurnId, @@ -1870,18 +1898,18 @@ export default function ChatView(props: ChatViewProps) { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]); + }, [activeThread?.id, embedded, focusComposerStable, terminalUiState.terminalOpen]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; let frame: number | null = null; const onWindowFocus = () => { if (frame !== null) window.cancelAnimationFrame(frame); @@ -1890,7 +1918,12 @@ export default function ChatView(props: ChatViewProps) { frame = window.requestAnimationFrame(() => { frame = null; if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; - if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable(); + if ( + !isInSideChatPane(document.activeElement) && + shouldRefocusComposerOnWindowFocus(document.activeElement) + ) { + focusComposerStable(); + } }); }); }; @@ -1899,7 +1932,13 @@ export default function ChatView(props: ChatViewProps) { window.removeEventListener("focus", onWindowFocus); if (frame !== null) window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + isMobileViewport, + terminalUiState.terminalOpen, + ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. const [localDispatchThreadId, setLocalDispatchThreadId] = useState(threadId); @@ -2313,8 +2352,8 @@ export default function ChatView(props: ChatViewProps) { // composer effect above. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the open drawer terminal when entering a thread useEffect(() => { - if (activeThreadRef) return focusTerminalOnThreadEnter(activeThreadRef); - }, [activeThreadRef]); + if (activeThreadRef && !embedded) return focusTerminalOnThreadEnter(activeThreadRef); + }, [activeThreadRef, embedded]); const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, @@ -2324,7 +2363,9 @@ export default function ChatView(props: ChatViewProps) { previewOpen: previewPanelOpen, editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", + composerFocus: + document.activeElement?.getAttribute("data-testid") === "composer-editor" && + isInSideChatPane(document.activeElement) === embedded, draftThreadRoute: routeKind === "draft", turnRunning: phase === "running", composerRequiresModifier: composerRequiresModifier(settings.sendShortcut, promptRef.current), @@ -2345,6 +2386,8 @@ export default function ChatView(props: ChatViewProps) { useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, + embedded, + composerElement: composerOverlayElement, keybindings, scriptKeybindings, getShortcutContext, @@ -2396,7 +2439,49 @@ export default function ChatView(props: ChatViewProps) { }, }); - usePasteToFocusComposer(activeThreadId, composerRef); + usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef); + // A delegated task is a remote thread's subagent. + const isSubagentThread = + activeThread?.lineage.relationshipToParent === "subagent" || delegatedFrom !== null; + // Side chats do not nest or start from subagents, and older servers would fork a listed thread. + const sideChatsOffered = + !embedded && + canOperateThread && + activeThread?.lineage.sideChat !== true && + !isSubagentThread && + serverConfig?.environment.capabilities.sideChats === true; + const sideChat = useSideChat({ + threadRef: sideChatsOffered && isServerThread ? activeThreadRef : null, + onError: (message) => { + if (activeThread) setThreadError(activeThread.id, message); + }, + }); + // A maximized panel covers the terminal drawer the command runs in. + const runSideChatShellCommandStable = useStableCallback((command: string) => { + if (activeThreadRef) useRightPanelStore.getState().setMaximized(activeThreadRef, false); + runShellCommandStable(command); + }); + // Main-thread surfaces show this reason; `/side` sent anyway gets it as a toast. + const sideChatDisabledReason = + activeThread?.lineage.sideChat === true + ? "Side chats cannot start side chats." + : isSubagentThread + ? "Subagent threads cannot start side chats." + : serverConfig === null + ? "Side chats are not ready yet. Try again in a moment." + : serverConfig.environment.capabilities.sideChats !== true + ? "Update this Coder workspace's T3 server to use side chats." + : sideChatsOffered && !isServerThread + ? "Side chats start after the first message." + : sideChat.available && + orchestrationV2WorktreePending( + activeThread?.worktreePath ?? null, + serverProjection?.runs ?? [], + ) + ? "Available once the thread's worktree is set up." + : null; + const openSideChatStable = useStableCallback(() => void sideChat.open()); + const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -2549,6 +2634,8 @@ export default function ChatView(props: ChatViewProps) { anchorNewTurn, acknowledgeActiveThreadWoke, handleInteractionModeChange, + onSideChat: sideChat.available ? sendSideChatStable : undefined, + sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, }); @@ -2646,10 +2733,7 @@ export default function ChatView(props: ChatViewProps) { !serverProjection?.messages.some((committed) => committed.id === message.id), ); const scrollToEndSummary = - [ - childInputNow === null ? null : "Subagent waiting", - queuedRunCount === 0 ? null : `${queuedRunCount} queued`, - ] + [childInputNow?.waitingLabel ?? null, queuedRunCount === 0 ? null : `${queuedRunCount} queued`] .filter((part) => part !== null) .join(" · ") || null; // The end of the timeline holds the thread's present and next turns: what it is doing now, @@ -2727,7 +2811,7 @@ export default function ChatView(props: ChatViewProps) { ); if (!activeThread) { - return ; + return embedded ? null : ; } const rightPanelContent = activeThreadRef ? ( @@ -2740,6 +2824,11 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget={composerDraftTarget} workspaceMutationId={workspaceMutationId} getShortcutContext={getShortcutContextStable} + onCloseSurface={closeRightPanelSurface} + sideChatParentPanel={{ + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -2819,7 +2908,7 @@ export default function ChatView(props: ChatViewProps) { ref={setWorkspaceLayoutElement} className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" > - {rightPanelControlsAtRoot ? panelLayoutControls : null} + {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
-
- {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
+ {embedded ? null : ( +
+ {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + +
+ )}
- + {embedded ? null : ( + <> + - + keybindings={keybindings} + gitCwd={gitCwd} + isGitRepo={isGitRepo} + envLocked={envLocked} + availableEnvironments={logicalProjectEnvironments} + {...(canChangeEnvironment + ? { onEnvironmentChange: onEnvironmentChangeStable } + : {})} + onEnvModeChange={onEnvModeChangeStable} + envMode={envMode} + {...(canOverrideServerThreadEnvMode + ? { + activeThreadBranchOverride: activeThreadBranch, + onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + } + : {})} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} + {...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } + : {})} + onComposerFocusRequest={scheduleComposerFocusStable} + {...(isServerThread && isGitRepo + ? { onOpenChanges: openChangesFromThreadPanelStable } + : {})} + // Coder: changed files in the Git actions dialog open in the Files surface. + {...(isServerThread ? { onOpenFile: openFileSurfaceStable } : {})} + onRunProjectScript={runProjectScriptStable} + onAddProjectScript={saveProjectScriptStable} + onUpdateProjectScript={updateProjectScriptStable} + onDeleteProjectScript={deleteProjectScriptStable} + usage={{ contextWindow: activeContextWindow }} + /> - + + + )}
- + {embedded ? null : ( + + )}
- {rightPanelPresent && activeThreadRef ? ( + {rightPanelPresent && activeThreadRef && !embedded ? ( {rightPanelContent} ) : null} - + {embedded ? null : }
diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 8cf712b113cf7..c034c7f4131c1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { + newThreadNoProjectIndex, buildNestedSearchItems, resolveCommandPaletteProject, buildThreadActionItems, @@ -938,3 +939,25 @@ it("resolves project metadata and browse roots within the active environment", ( expect(resolveCommandPaletteProject(projects, null, sharedId)).toBeUndefined(); expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), null)).toBeUndefined(); }); + +describe("newThreadNoProjectIndex", () => { + const entry = (pinned = false, isPreferred = false, isScratch = false) => ({ + pinned, + isPreferred, + isScratch, + }); + + it("goes after the pinned projects and the first unpinned one", () => { + expect(newThreadNoProjectIndex([entry(true), entry(true), entry(false, true), entry()])).toBe( + 3, + ); + expect(newThreadNoProjectIndex([entry(false, true), entry(), entry(false, false, true)])).toBe( + 1, + ); + }); + + it("keeps a pinned or current scratch project in its own place", () => { + expect(newThreadNoProjectIndex([entry(false, true, true), entry()])).toBe(0); + expect(newThreadNoProjectIndex([entry(true), entry(true, false, true), entry()])).toBe(1); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 96d439252ac53..cc8fd9091ded8 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -32,7 +32,7 @@ import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/fil import { type ReactNode } from "react"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; -import { isSidebarSubagentThread } from "./Sidebar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; @@ -208,6 +208,14 @@ export interface CommandPaletteActionItem extends CommandPaletteItem { readonly kind: "action"; readonly keepOpen?: boolean; readonly run: () => Promise; + /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */ + readonly pin?: CommandPaletteItemPin; +} + +export interface CommandPaletteItemPin { + readonly name: string; + readonly pinned: boolean; + readonly toggle: () => void; } export interface CommandPaletteSubmenuItem extends CommandPaletteItem { @@ -364,24 +372,29 @@ export function buildProjectActionItems(input: { searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; shortcutCommand?: KeybindingCommand; + pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null; }): CommandPaletteActionItem[] { - return input.projects.map((project) => ({ - kind: "action", - value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [ - project.displayName, - project.title, - project.workspaceRoot, - ...(input.searchTerms?.(project) ?? []), - ], - title: project.displayName, - description: input.renderDescription?.(project) ?? project.workspaceRoot, - icon: input.icon(project), - ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), - run: async () => { - await input.runProject(project); - }, - })); + return input.projects.map((project) => { + const pin = input.pin?.(project) ?? null; + return { + kind: "action", + value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, + searchTerms: [ + project.displayName, + project.title, + project.workspaceRoot, + ...(input.searchTerms?.(project) ?? []), + ], + title: project.displayName, + description: input.renderDescription?.(project) ?? project.workspaceRoot, + icon: input.icon(project), + ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), + ...(pin ? { pin } : {}), + run: async () => { + await input.runProject(project); + }, + }; + }); } export type BuildThreadActionItemsThread = Pick< @@ -421,7 +434,7 @@ export function buildThreadActionItems thread.archivedAt === null && !isSidebarSubagentThread(thread), + (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread), ); return visibleThreads.map((thread) => { @@ -869,3 +882,25 @@ export function resolveCommandPaletteProject( ) { return environmentId && projectId ? projects.get(`${environmentId}:${projectId}`) : undefined; } + +/** + * Where "No project" goes among the new-thread picker's real projects: at the + * scratch project's own place when it is pinned or current, else after the + * pinned projects and the first unpinned one. + */ +export function newThreadNoProjectIndex( + entries: ReadonlyArray<{ + readonly isScratch: boolean; + readonly pinned: boolean; + readonly isPreferred: boolean; + }>, +): number { + const projects = entries.filter((entry) => !entry.isScratch); + const scratchAt = entries.findIndex((entry) => entry.isScratch); + const scratch = entries[scratchAt]; + if (scratch && (scratch.pinned || scratch.isPreferred)) { + return entries.slice(0, scratchAt).filter((entry) => !entry.isScratch).length; + } + const pinnedCount = projects.filter((entry) => entry.pinned).length; + return Math.min(pinnedCount + 1, projects.length); +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 13c1d635a9028..a79776c03eed1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -62,6 +62,8 @@ import { RotateCcwIcon, SettingsIcon, MonitorIcon, + PinIcon, + PinOffIcon, SquarePenIcon, TextSearchIcon, } from "lucide-react"; @@ -95,9 +97,12 @@ import { threadEnvironment } from "../state/threads"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; import { useNewProject } from "../hooks/useNewProject"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { reportProjectFailure, useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useEnvironments } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -133,6 +138,7 @@ import { buildLinkedThreadActionItems, buildCommandPaletteRows, enumerateCommandPaletteItems, + newThreadNoProjectIndex, findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, @@ -204,6 +210,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; @@ -241,6 +248,8 @@ function openIntentView(intent: CommandPaletteOpenIntent | null): CommandPalette } } +const NEW_THREAD_NO_PROJECT_VALUE = "new-thread-in:no-project"; + export function CommandPalette({ children }: { children: ReactNode }) { useCommandPaletteMount(); const state = useCommandPaletteStore(); @@ -379,6 +388,9 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const updateProject = useOrchestrationCommand(projectEnvironment.update, { + label: "project unarchive", + }); const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, @@ -462,7 +474,7 @@ function OpenCommandPaletteDialog(props: { handleNewThread, }); const projectPickerEntries = buildSidebarProjectPickerEntries({ - groups: projectGroups, + groups: pickableProjectGroups(projectGroups), preferredProjectRef: contextualProjectRef, }); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ @@ -475,6 +487,21 @@ function OpenCommandPaletteDialog(props: { group, ]), ); + const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); + // A row's target record can change when another machine's copy loads, so the kept + // order follows the project group instead. + const rowOrderKey = (prefix: string) => (item: CommandPaletteActionItem) => + projectGroupByTargetKey.get(item.value.slice(prefix.length + 1))?.projectKey ?? item.value; + const projectPin = (project: CommandPaletteProject) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return group && canPinProjectGroup(group) + ? { + name: group.displayName, + pinned: group.pinned, + toggle: () => toggleProjectGroupPin(group), + } + : null; + }; const addProjectEnvironmentOptions = (() => { const options = environments @@ -701,21 +728,28 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }; - const projectSearchItems = buildProjectSearchItems({ - projects: pickerProjects, - projectGroupByTargetKey, - locationByEnvironmentId: projectEnvironmentLocationById, - runProject: openProjectFromSearch, - }); + // Pinning from a row keeps rows in place while the palette is open. + const projectSearchItems = useOrderWhileOpen( + buildProjectSearchItems({ + projects: pickerProjects, + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, + runProject: openProjectFromSearch, + pin: projectPin, + }), + rowOrderKey("project"), + true, + ); - const projectThreadItems = (() => { - const isScratch = (project: CommandPaletteProject) => - isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); - const projectItems = enumerateCommandPaletteItems( - buildNewThreadInProjectItems({ + const openingOrderThreadItems = useOrderWhileOpen( + (() => { + const isScratch = (project: CommandPaletteProject) => + isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); + const projectItems = buildNewThreadInProjectItems({ projects: pickerProjects.filter((project) => !isScratch(project)), projectGroupByTargetKey, locationByEnvironmentId: projectEnvironmentLocationById, + pin: projectPin, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -731,25 +765,45 @@ function OpenCommandPaletteDialog(props: { : scopeProjectRef(project.environmentId, project.id), ); }, - }), - ); - if (scratchTargetEnvironmentId === null) return projectItems; + }); + if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; - return [ - ...projectItems.slice(0, noProjectIndex), - { - kind: "action" as const, - value: "new-thread-in:no-project", - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, - ...projectItems.slice(noProjectIndex), - ]; - })(); + const noProjectIndex = newThreadNoProjectIndex( + projectPickerEntries.map(({ group, targetProject, isPreferred }) => ({ + isScratch: isScratchProject( + targetProject, + scratchWorkspaceRootFor(targetProject.environmentId), + ), + pinned: group.pinned, + isPreferred, + })), + ); + return [ + ...projectItems.slice(0, noProjectIndex), + { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ...projectItems.slice(noProjectIndex), + ]; + })(), + rowOrderKey("new-thread-in"), + true, + ); + // Jump shortcuts follow the rows as shown, so a pin cannot move a number to another row. + const projectJumpItems = new Map( + enumerateCommandPaletteItems( + openingOrderThreadItems.filter((item) => item.value !== NEW_THREAD_NO_PROJECT_VALUE), + ).map((item) => [item.value, item] as const), + ); + const projectThreadItems = openingOrderThreadItems.map( + (item) => projectJumpItems.get(item.value) ?? item, + ); const { threadItems: allThreadItems, @@ -1391,7 +1445,7 @@ function OpenCommandPaletteDialog(props: { }); } - actionItems.push({ + const addProjectAction: CommandPaletteActionItem = { kind: "action", value: "action:add-project", searchTerms: [ @@ -1419,7 +1473,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { openAddProjectFlow(); }, - }); + }; + actionItems.push(addProjectAction); actionItems.push(changeThemeItem); actionItems.push(changeAppearanceItem); @@ -1481,14 +1536,33 @@ function OpenCommandPaletteDialog(props: { }, }); - const contextualProjectGroup = + // Every group, so an archived project's thread still opens its own settings. + const currentProjectGroup = (contextualProjectRef - ? projectGroupByTargetKey.get( - `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ? projectGroups.find((group) => + group.memberProjectRefs.some( + (ref) => + ref.environmentId === contextualProjectRef.environmentId && + ref.projectId === contextualProjectRef.projectId, + ), ) - : null) ?? - projectGroups[0] ?? - null; + : null) ?? null; + if (currentProjectGroup && canPinProjectGroup(currentProjectGroup)) { + actionItems.push({ + kind: "action", + value: "action:project-pin", + searchTerms: ["pin", "unpin", "project", "favorite"], + title: currentProjectGroup.pinned ? "Unpin project" : "Pin project", + description: currentProjectGroup.displayName, + icon: currentProjectGroup.pinned ? ( + + ) : ( + + ), + run: async () => toggleProjectGroupPin(currentProjectGroup), + }); + } + const contextualProjectGroup = currentProjectGroup ?? projectGroups[0] ?? null; if (contextualProjectGroup) { actionItems.push({ kind: "action", @@ -1554,7 +1628,14 @@ function OpenCommandPaletteDialog(props: { isAddProjectEnvironmentView ? addProjectEntryGroups() : currentView?.groups[0]?.value === NEW_THREAD_IN_GROUP - ? [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }] + ? [ + { + value: NEW_THREAD_IN_GROUP, + label: "Projects", + // Every project archived and no "No project" home leaves only adding one. + items: projectThreadItems.length > 0 ? projectThreadItems : [addProjectAction], + }, + ] : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups : currentView?.groups[0]?.value === "appearance" @@ -1646,6 +1727,13 @@ function OpenCommandPaletteDialog(props: { cwd, ); if (existing) { + // Adding an archived project's folder again brings the project back. + if (existing.archivedAt != null) { + void updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }).then((result) => reportProjectFailure("Failed to unarchive project", result)); + } const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, @@ -2221,6 +2309,19 @@ function OpenCommandPaletteDialog(props: { platform: navigator.platform, context: { modelPickerOpen: false, editableFocus: true }, }); + if (command === "thread.pin") { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? + (autoHighlightsFirstRow ? (resultRows.itemValues[0] ?? null) : null), + ); + if (highlightedItem?.kind === "action" && highlightedItem.pin) { + event.preventDefault(); + event.stopPropagation(); + highlightedItem.pin.toggle(); + return; + } + } // Global new-thread handlers ignore shortcuts while the palette is open. const isJump = threadJumpIndexFromCommand(command ?? "") !== null; const isNewThread = diff --git a/apps/web/src/components/CommandPaletteProjectItems.tsx b/apps/web/src/components/CommandPaletteProjectItems.tsx index 90373413be4fa..2a3d466474d2e 100644 --- a/apps/web/src/components/CommandPaletteProjectItems.tsx +++ b/apps/web/src/components/CommandPaletteProjectItems.tsx @@ -5,6 +5,7 @@ import type { Project } from "../types"; import { buildCommandPaletteProjectMetadata, buildProjectActionItems, + type CommandPaletteItemPin, type CommandPaletteProject, ITEM_ICON_CLASS, } from "./CommandPalette.logic"; @@ -29,6 +30,7 @@ interface ProjectItemsInput { readonly projectGroupByTargetKey: ReadonlyMap; readonly locationByEnvironmentId: ReadonlyMap; readonly runProject: (project: CommandPaletteProject) => Promise; + readonly pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null; } function projectFaviconIcon(project: Project) { @@ -66,6 +68,7 @@ export function buildProjectSearchItems(input: ProjectItemsInput) { }, icon: projectFaviconIcon, runProject: input.runProject, + ...(input.pin ? { pin: input.pin } : {}), }); } @@ -90,6 +93,7 @@ export function buildNewThreadInProjectItems(input: ProjectItemsInput) { ), icon: projectFaviconIcon, runProject: input.runProject, + ...(input.pin ? { pin: input.pin } : {}), }); } diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index c481d57d5fc8c..e3cc3a3791afa 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -22,6 +22,7 @@ import { CommandShortcut, } from "./ui/command"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; +import { ProjectPinButton } from "./ProjectPinButton"; import { SearchMatchHintLine, ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; interface CommandPaletteResultsProps { @@ -268,6 +269,14 @@ function CommandPaletteResultRow(props: {
)} {props.item.titleTrailingContent} + {props.item.kind === "action" && props.item.pin ? ( + + ) : null} {props.item.timestamp ? ( {props.item.timestamp} diff --git a/apps/web/src/components/ProjectPinButton.tsx b/apps/web/src/components/ProjectPinButton.tsx new file mode 100644 index 0000000000000..a8bd7d5ee8e99 --- /dev/null +++ b/apps/web/src/components/ProjectPinButton.tsx @@ -0,0 +1,67 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { PinIcon, PinOffIcon } from "lucide-react"; +import type { KeyboardEvent } from "react"; + +import { resolveShortcutCommand } from "../keybindings"; + +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; + +/** The thread pin shortcut, which pins the highlighted row in a project list. */ +export function isProjectPinShortcut( + event: KeyboardEvent, + keybindings: ResolvedKeybindingsConfig, +): boolean { + return ( + resolveShortcutCommand(event.nativeEvent, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false, editableFocus: true }, + }) === "thread.pin" + ); +} + +/** + * Pin toggle for a project row in a menu or list. A pinned project always shows its pin; + * an unpinned one shows it only on the highlighted row. Keyboard users get a shortcut + * from the list that hosts it, so the button stays out of the tab order. + */ +export function ProjectPinButton(props: { + readonly name: string; + readonly pinned: boolean; + readonly onToggle: () => void; + readonly shortcutLabel?: string | null; +}) { + const action = `${props.pinned ? "Unpin" : "Pin"} ${props.name}`; + return ( + + + + ); +} diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index 5402d95a8fd5f..f6a9f226a6167 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -76,6 +76,7 @@ export function ReopenClosedViewShortcut() { environmentKnown: true, catalogReady: true, ownerExists: true, + sideChatExists: true, shellLive: true, panel, }; @@ -84,6 +85,10 @@ export function ReopenClosedViewShortcut() { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + sideChatExists: + entry.kind !== "panel-tab" || + entry.surface.kind !== "side-chat" || + readThreadShell(scopeThreadRef(ref.environmentId, entry.surface.threadId)) !== null, shellLive: appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live", panel, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 138e4d99d6fbe..0c0ed17a55cec 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,8 +1,17 @@ // Coder: upstream's tab strip without its browser, device, and preview surfaces; see // "Right panel and composer chrome" in docs/internals/coder-only.md. import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + threadRuntimeIsActive, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + useProjects, + useQuietThreadShell, + useServerConfigs, + useThreadShells, +} from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -12,6 +21,7 @@ import type { ProjectId, PullRequestState, ResolvedKeybindingsConfig, + ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -31,6 +41,7 @@ import { FileDiff, Files, Globe2, + MessagesSquare, Plus, Smartphone, TerminalSquare, @@ -103,11 +114,16 @@ export interface RightPanelTabsProps { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; + sideChatAvailable?: boolean; + /** Overrides the default reason while side chats are available but cannot start. */ + sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -129,6 +145,7 @@ const SURFACE_DISABLED_REASONS = { diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no merge request yet.", pullRequests: "No linked merge requests are available for this thread.", + sideChat: "Side chats start after the first message.", } as const; // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. @@ -151,6 +168,7 @@ const SURFACE_UNAVAILABLE_HINTS = { diff: "Available for Git repositories.", pullRequest: "No merge request on this branch yet.", pullRequests: "No linked merge requests available.", + sideChat: "Available after the first message.", } as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; @@ -226,11 +244,15 @@ function RightPanelEmptyState(props: { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; + sideChatAvailable: boolean; + sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -275,6 +297,19 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + description: "Ask a side question while this thread keeps working.", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; type SurfaceAction = (typeof actions)[number]; @@ -424,6 +459,8 @@ function surfaceTitle( return `!${surface.number}`; case "pull-requests": return "Merge requests"; + case "side-chat": + return "Side chat"; // Coder: preview and device surfaces are never opened. case "device": return "Device"; @@ -469,6 +506,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "side-chat": + return ; // Coder: preview and device surfaces are never opened. case "preview": return ; @@ -477,6 +516,96 @@ function SurfaceIcon({ } } +/** What a side chat tab's dot shows and its title's accessible name says. */ +function sideChatTabState(shell: EnvironmentThreadShell | null) { + if (shell === null) return null; + if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { + return "needs approval"; + } + if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { + return "needs input"; + } + return threadRuntimeIsActive(shell.runtime) ? "running" : null; +} + +// Static dots: a side chat tab can stay open for a whole run. +function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { + const state = sideChatTabState( + useQuietThreadShell( + props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), + ), + ); + return ( + <> + + {state === null ? null : ( + + )} + + ); +} + +// Coder: upstream reads a side chat's shell in its memoized RightPanelTab; the fork's inline tab +// strip reads it here, so the tab shows the side chat's title and state. +function RightPanelTabContent(props: { + surface: RightPanelSurface; + title: string; + pending: boolean; + theme: "light" | "dark"; + environmentId: EnvironmentId | null; + pullRequestStatusSeeds: Readonly> | undefined; + onActivate: (surface: RightPanelSurface) => void; + onClose: (surface: RightPanelSurface) => void; +}) { + const { surface } = props; + const sideChatShell = useQuietThreadShell( + surface.kind === "side-chat" && props.environmentId !== null + ? scopeThreadRef(props.environmentId, surface.threadId) + : null, + ); + const title = sideChatShell?.title ?? props.title; + const sideChatState = sideChatTabState(sideChatShell); + return ( + <> + props.onClose(surface)}> + + {props.pending ? ( + + ) : null} + + + props.onActivate(surface)} + > + {title} + + } + /> + {title} + + + ); +} + export function resolvePullRequestTabLink( threads: readonly Pick[], environmentId: EnvironmentId | null, @@ -725,6 +854,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequests, onClick: props.onAddPullRequests, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -904,37 +1045,16 @@ export function RightPanelTabs(props: RightPanelTabsProps) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - props.onCloseSurface(surface)} - > - - {pending ? ( - - ) : null} - - - props.onActivate(surface)} - > - {title} - - } - /> - {title} - + ); })} @@ -1043,11 +1163,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} + onAddSideChat={props.onAddSideChat} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} + sideChatAvailable={props.sideChatAvailable === true} + sideChatDisabledReason={props.sideChatDisabledReason ?? null} /> ) : ( props.children diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 68d8577690643..f6e66a1ac6506 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { + shouldCreateNewThreadInCurrentProject, archiveSelectedThreadEntries, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, @@ -1630,6 +1631,12 @@ describe("groupSidebarThreadsByProject", () => { projectKey: "fleet", memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: fleet }], }, + { + projectKey: "notes", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: ProjectId.make("notes") }, + ], + }, ]; const threads = [ @@ -1637,17 +1644,23 @@ describe("groupSidebarThreadsByProject", () => { thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), ]; - const summarize = (keepEmpty: boolean) => + const summarize = (keepEmpty: (group: (typeof groups)[number]) => boolean) => groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), })); - expect(summarize(true)).toEqual([ + expect(summarize(() => true)).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + { projectKey: "notes", threads: [] }, + ]); + expect(summarize(() => false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); + // A pinned project keeps its empty folder while other empty projects drop out. + expect(summarize((group) => group.projectKey === "fleet")).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); - expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); @@ -1687,3 +1700,13 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); }); }); + +describe("shouldCreateNewThreadInCurrentProject", () => { + it("opens the picker when several projects are open, or only archived ones exist", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 2)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 1, 3)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 0)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 73e9a20bc6061..550dd575b2057 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,7 +16,7 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { isListedThreadLineage, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; import type { ContextMenuItem } from "../localApiTypes"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; @@ -591,12 +591,12 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; +export function isSidebarHiddenThread(thread: Pick): boolean { + return !isListedThreadLineage(thread.lineage); } -// Subagent threads have no sidebar row, so an open subagent is represented by -// its nearest non-subagent ancestor. A broken chain (missing parent, cycle, +// Subagent and side-chat threads have no sidebar row, so an open one is represented by +// its nearest listed ancestor. A broken chain (missing parent, cycle, // other environment) resolves to the thread itself, which then matches no row. export function resolveSidebarRowThreadId( threadId: ThreadId, @@ -607,7 +607,7 @@ export function resolveSidebarRowThreadId( threads.find((thread) => thread.id === id && thread.environmentId === environmentId); const visited = new Set(); let thread = findThread(threadId); - while (thread !== undefined && isSidebarSubagentThread(thread)) { + while (thread !== undefined && isSidebarHiddenThread(thread)) { if (thread.lineage.parentThreadId === null || visited.has(thread.id)) return threadId; visited.add(thread.id); thread = findThread(thread.lineage.parentThreadId); @@ -776,11 +776,13 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { return nodeClosest(parent, "a[href]") !== null; } +/** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */ export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, + openProjectGroupCount: number, projectGroupCount: number, ): boolean { - return shiftKey || projectGroupCount <= 1; + return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; } export function orderItemsByPreferredIds(input: { @@ -967,7 +969,7 @@ export function sidebarFolderExpansionKeys(group: { /** * Boring sidebar folders. Folders follow `groups` order and threads keep their - * input order. A project with no threads gets a folder only with `keepEmpty`. + * input order. A project with no threads gets a folder only when `keepEmpty` says so. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -981,7 +983,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], - keepEmpty: boolean, + keepEmpty: (group: TGroup) => boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -993,7 +995,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); + return folders.filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group)); } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 93245e9e70268..53475d57c18d9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -103,7 +103,10 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { selectProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + selectProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import { projectGroupsSpanEnvironments, resolveSidebarProjectScopeKeys, @@ -121,6 +124,8 @@ import { useThreadActions, } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -227,6 +232,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { Button } from "./ui/button"; +import { isProjectPinShortcut, ProjectPinButton } from "./ProjectPinButton"; import { Combobox, ComboboxEmpty, @@ -405,8 +411,8 @@ function useSidebarThreads(input: { readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; - // Keeps folders with no active threads, so a project the user filtered to can start one. - readonly keepEmptyFolders: boolean; + // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. + readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; }) { const { projectScopeKey, @@ -808,7 +814,10 @@ function useSidebarLookups() { ); const projectScopeItems = [ { value: "all", label: "All projects" }, - ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName })), + ...pickableProjectGroups(projectGroups).map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), ]; return { projects, @@ -883,7 +892,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectByKey, projectDisplayNameByKey, projectGroupByScopeKey, - projectScopeItems, + projectScopeItems: lookupProjectScopeItems, } = useSidebarLookups(); const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); @@ -1051,13 +1060,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; // A scope whose project is gone reads as all projects once every snapshot has // loaded. The stored keys are left alone, so the scope returns with its project. + // An archived project's scope reads as all projects, matching the menu. + const scopeGroups = pickableProjectGroups(projectGroups); + const archivedProjectKeys = new Set( + projectGroups.filter((project) => project.archived).map((project) => project.projectKey), + ); const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys({ groups: projectGroups, keys: persistedProjectScopeKeys, settings: projectGroupingSettings, canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, - }); - const chosenProjectGroups = projectGroups.filter((project) => + }).filter((key) => !archivedProjectKeys.has(key)); + const chosenProjectGroups = scopeGroups.filter((project) => chosenProjectScopeKeys.includes(project.projectKey), ); const chosenScopeProjectKeys = @@ -1131,6 +1145,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return environmentLabelById.get(thread.environmentId) ?? null; }; + // Pinning from a row keeps rows in place while the menu is open. + const projectScopeItems = useOrderWhileOpen( + lookupProjectScopeItems, + (item) => item.value, + projectScopeMenuState.open, + ); const selectedProjectScopeItem = projectScopeKeys.length > 1 ? null @@ -1191,6 +1211,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }, ); + const { + canPinProjectGroup: canPinProject, + setProjectGroupPinned, + toggleProjectGroupPin, + } = useProjectPin(); const handleProjectSettings = useStableCallback( ( event: ReactMouseEvent | ReactKeyboardEvent, @@ -1217,6 +1242,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve [ { id: "new-thread", label: "New thread" }, { id: "copy-path", label: "Copy path" }, + ...(canPinProject(group) + ? [ + group.pinned + ? { id: "unpin", label: "Unpin project", icon: "pin-off" } + : { id: "pin", label: "Pin project", icon: "pin" }, + ] + : []), { id: "project-settings", label: "Project settings", icon: "settings" }, ], position, @@ -1230,6 +1262,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "copy-path": copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); return; + case "pin": + case "unpin": + setProjectGroupPinned(group, clicked.value === "pin"); + return; case "project-settings": openProjectSettings(group); return; @@ -1285,10 +1321,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeRowThreadKey, folderGroups: boringSidebarEnabled ? scopedProjectGroups.length === 0 - ? projectGroups + ? // An archived project keeps a folder for its active threads, below the rest. + [...scopeGroups, ...projectGroups.filter((project) => project.archived)] : scopedProjectGroups : null, - keepEmptyFolders: scopedProjectGroups.length > 0, + keepEmptyFolders: (group) => + scopedProjectGroups.length > 0 || (group.pinned && !group.archived), }); // Ranked across environments as the palette ranks them, so one @@ -1859,7 +1897,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); - // Boring mode keeps a project the user filtered to, so it can start a thread. + // Boring mode keeps a pinned open project or one the user filtered to, so it can start a thread. if ( !hasProjectFolders && pinnedThreads.length + @@ -2549,12 +2587,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve AuthOrchestrationOperateScope, ), branch: thread.branch ?? null, - projectFilter: threadProjectGroup - ? { - label: threadProjectGroup.displayName, - isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), - } - : null, + // An archived project is not a filter choice. + projectFilter: + threadProjectGroup && !threadProjectGroup.archived + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), + } + : null, isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, @@ -2867,14 +2907,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( boringSidebarEnabled || - shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + scopeGroups.length, + projectGroups.length, + ) ) { if (isMobile) setOpenMobile(false); + // With no current or open project, such as when every project is archived, pick one. void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, defaultProjectRef: newThreadContext.defaultProjectRef, handleNewThread, + }).then((started) => { + if (!started) openCommandPalette({ open: "new-thread-in" }); }); return; } @@ -2884,7 +2931,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (shouldCreateNewThreadInCurrentProject(false, scopeGroups.length, projectGroups.length) + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -2976,15 +3025,24 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( event.defaultPrevented || event.nativeEvent.isComposing || - event.keyCode === 229 || - event.ctrlKey || - event.altKey || - event.metaKey + event.keyCode === 229 ) { return; } const scopeKey = highlightedProjectScopeKeyRef.current; const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + // The thread pin shortcut pins the highlighted project here. + if ( + project && + canPinProject(project) && + isProjectPinShortcut(event, keybindings) + ) { + event.preventDefault(); + event.stopPropagation(); + toggleProjectGroupPin(project); + return; + } + if (event.ctrlKey || event.altKey || event.metaKey) return; if (event.shiftKey && event.key === "Enter") { if (!project) return; event.preventDefault(); @@ -3040,6 +3098,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve machineByEnvironmentId={environmentMachineById} /> ) : null} + {project && canPinProject(project) ? ( + toggleProjectGroupPin(project)} + /> + ) : null} {project ? ( ; + return ( + + }>{content} + + {props.description} + + + ); +} + +function moveBetweenRows(event: KeyboardEvent) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const rows = Array.from( + event.currentTarget.querySelectorAll("[data-effort-row]:not(:disabled)"), + ); + const index = rows.findIndex((row) => row === document.activeElement); + const next = + rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]; + if (!next) return; + event.preventDefault(); + event.stopPropagation(); + next.focus(); + next.scrollIntoView({ block: "nearest" }); +} + +function EffortLevels(props: { traits: TraitsSelectionInput }) { + const traits = useTraitsSelection(props.traits); + const { effort, others } = columnControls( + props.traits.provider, + traits.selectDescriptors, + traits.booleanDescriptors, + ); + if (traits.modelIsUnavailable) { + return ( + <> + {traits.descriptors.map((descriptor) => { + const value = traits.currentLabel(descriptor); + return value ? ( +
+
{descriptor.label}
+
{value}
+
+ ) : null; + })} + + ); + } + if (!effort && others.length === 0) return ; + const renderControl = (control: ColumnControl) => { + const isEffort = control === effort; + const { descriptor } = control; + if (control.kind === "boolean") { + return ( +
+ {isEffort ? null :
{descriptor.label}
} + {([true, false] as const).map((value) => ( + traits.changeBoolean(descriptor.id, value)} + /> + ))} +
+ ); + } + const locked = + descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; + const value = traits.selectValue(control.descriptor); + return ( +
+ {isEffort ? null :
{descriptor.label}
} + {locked ? ( +
+ {ULTRATHINK_IN_BODY_NOTE} +
+ ) : null} + {selectableProviderOptionChoices(control.descriptor).map((option) => ( + traits.changeSelect(control.descriptor, option.id)} + /> + ))} +
+ ); + }; + return ( + <> + {effort ? renderControl(effort) : null} + {others.map(renderControl)} + + ); +} + +function NoEffortRow() { + return ( +
No effort levels
+ ); +} + +/** The model picker's effort column: the selected model's levels and its other options. */ +export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | null }) { + const picker = use(DockedPickerContext); + const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; + const effort = + props.traits && visibility + ? columnControls( + props.traits.provider, + visibility.selectDescriptors, + visibility.booleanDescriptors, + ).effort + : null; + return ( + <> + {/* Matches the search row's box, so one rule runs across the top of the popup. */} +
+
+ {effort?.descriptor.label ?? "Effort"} + + {picker.modelLabel} + +
+
+ {/* pt-2.5 puts the first level on the same line as the first model. */} +
+ {props.traits ? : } +
+ + ); +} + +const FOOTER_SEGMENT = + "flex h-6 cursor-pointer items-center justify-center gap-1 px-2 text-muted-foreground text-xs outline-none first:rounded-l-md last:rounded-r-md hover:text-foreground data-checked:bg-input/72 data-checked:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"; + +function SpeedSegment(props: { + checked: boolean; + label: string; + description?: string | undefined; + onPick: () => void; +}) { + const buttonProps = { + type: "button", + role: "radio", + "aria-checked": props.checked, + "data-checked": props.checked || undefined, + className: FOOTER_SEGMENT, + onClick: props.onPick, + } as const; + const content = ( + <> + {props.label === "Fast" || props.label === "Ultrafast" ? ( + + ) : null} + {props.label} + + ); + if (!props.description) return ; + return ( + + }>{content} + {props.description} + + ); +} + +function SpeedControl(props: { traits: TraitsSelectionInput }) { + const traits = useTraitsSelection(props.traits); + const { speedSelect, fastToggle } = splitSpeed( + props.traits.provider, + traits.selectDescriptors, + traits.booleanDescriptors, + ); + if (traits.modelIsUnavailable || (!speedSelect && !fastToggle)) return null; + return ( +
+ {speedSelect + ? selectableProviderOptionChoices(speedSelect).map((option) => ( + traits.changeSelect(speedSelect, option.id)} + /> + )) + : fastToggle + ? ([false, true] as const).map((value) => ( + traits.changeBoolean(fastToggle.id, value)} + /> + )) + : null} +
+ ); +} + +/** Permissions change rarely, so the footer names the mode and nests the choice in a menu. */ +function PermissionsMenu(props: { + runtimeMode: RuntimeMode; + options: ReadonlyArray; + onChange: (mode: RuntimeMode) => void; +}) { + const triggerRef = useRef(null); + const current = props.options.find((option) => option.mode === props.runtimeMode); + if (!current) return null; + const CurrentIcon = current.icon; + return ( + { + if (open) focusCheckedPermission(triggerRef.current); + }} + > + + } + > + + + + + + ); +} + +/** The model picker's footer: speed stays one click away, permissions sit one menu deeper. */ +export function ComposerPickerFooter(props: { + traits: TraitsSelectionInput | null; + runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; + onRuntimeModeChange: (mode: RuntimeMode) => void; +}) { + return ( +
+ {props.traits ? : null} + +
+ ); +} diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 60ee931fe5f04..37a002e2c2708 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -9,6 +9,7 @@ import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; +import { isInSideChatPane } from "~/lib/sideChatPane"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; @@ -151,6 +152,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; + if (isInSideChatPane(target)) return; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx index 74ab036f411e1..850256b28904a 100644 --- a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -16,20 +16,20 @@ import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); -// Mirrors the effort menu: effort radio items first, then the Permissions section. -function EffortMenu() { - const [effort, setEffort] = useState("high"); +// Mirrors the overflow menu: mode radio items first, then the Permissions section. +function OverflowMenu() { + const [interaction, setInteraction] = useState("plan"); const [mode, setMode] = useState("full-access"); const composerMenuProps = useComposerMenuProps(); const triggerRef = useRef(null); return ( <> focusRequestedPermission(open, triggerRef.current)}> - Effort + More - - Low - High + + Chat + Plan { - await using screen = await render(, { router: false }); // no routes involved - const trigger = screen.getByRole("button", { name: "Effort" }); +it("reaches Permissions from the mode items with arrow keys, previews, and picks with Enter", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); await userEvent.tab(); await expect.element(trigger).toHaveFocus(); await userEvent.keyboard("{ArrowDown}"); - await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}"); const supervised = page.getByRole("menuitemradio", { name: "Supervised" }); @@ -66,7 +66,7 @@ it("reaches Permissions from the effort items with arrow keys, previews, and pic await expect.element(page.getByRole("menuitemradio", { name: "Supervised" })).toHaveFocus(); await userEvent.keyboard("{ArrowUp}"); - await expect.element(page.getByRole("menuitemradio", { name: "High" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Plan" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); await expect.element(screen.getByRole("status")).toHaveTextContent("auto-accept-edits"); @@ -77,14 +77,14 @@ it("reaches Permissions from the effort items with arrow keys, previews, and pic }); it("marks the current mode as checked and closes on Escape", async () => { - await using screen = await render(, { router: false }); // no routes involved + await using screen = await render(, { router: false }); // no routes involved await userEvent.tab(); await userEvent.keyboard("{ArrowDown}"); const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); await expect.element(fullAccess).toHaveAttribute("aria-checked", "true"); await userEvent.keyboard("{Escape}"); await expect.element(fullAccess).not.toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Effort" })).toHaveFocus(); + await expect.element(screen.getByRole("button", { name: "More" })).toHaveFocus(); }); // The real composer menu focus rule (useComposerMenuProps), with a stub composer handle. @@ -95,19 +95,19 @@ function withComposer(children: ReactNode) { } it("returns focus to the editor after a pick", async () => { - const { handle, ui } = withComposer(); + const { handle, ui } = withComposer(); await using screen = await render(ui, { router: false }); // no routes involved await userEvent.tab(); await userEvent.keyboard("{ArrowDown}"); - await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); await expect.element(screen.getByRole("status")).toHaveTextContent("approval-required"); await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); }); it("opens on the checked permission when composer.mode marked the trigger", async () => { - await using screen = await render(, { router: false }); // no routes involved - const trigger = screen.getByRole("button", { name: "Effort" }); + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); markOpenToPermissions(trigger.element() as HTMLElement); await userEvent.click(trigger); await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).toHaveFocus(); @@ -124,7 +124,7 @@ function PermissionsChip() { return (
{mode === "full-access" ? null : ( expect(handle.focusAtEnd).toHaveBeenCalled()); - await expect.element(screen.getByRole("button", { name: "Effort" })).not.toHaveFocus(); + await expect.element(screen.getByRole("button", { name: "Model" })).not.toHaveFocus(); }); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 5c8dd7b09a8cf..b2580e3b54d89 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -11,11 +11,14 @@ import { openCommandPalette } from "~/commandPaletteBus"; import { shortcutLabelForCommand } from "~/keybindings"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useOrderWhileOpen } from "~/hooks/useOrderWhileOpen"; +import { useProjectPin } from "~/hooks/useProjectGroupUpdate"; import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, + pickableProjectGroups, selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { @@ -27,6 +30,7 @@ import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; +import { isProjectPinShortcut, ProjectPinButton } from "../ProjectPinButton"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Combobox, @@ -81,6 +85,8 @@ export function DraftHeroHeadline({ const [pickerOpen, setPickerOpen] = useState(false); const [pickerQuery, setPickerQuery] = useState(""); const pickerFilter = useComboboxFilter(); + const highlightedPickerValueRef = useRef(null); + const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); const environmentLabelById = new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), @@ -106,7 +112,7 @@ export function DraftHeroHeadline({ ), ); const projectPickerEntries = buildSidebarProjectPickerEntries({ - groups: projectGroups, + groups: pickableProjectGroups(projectGroups), preferredProjectRef: activeProjectRef, }); const projectEntryByKey = new Map( @@ -143,10 +149,15 @@ export function DraftHeroHeadline({ activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRootFor(activeProject.environmentId)); - const pickerItems: readonly PickerItem[] = [ - ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), - { value: ADD_PROJECT_VALUE, label: "Add project" }, - ]; + // Pinning from a row keeps rows in place while the picker is open. + const pickerItems: readonly PickerItem[] = useOrderWhileOpen( + [ + ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), + { value: ADD_PROJECT_VALUE, label: "Add project" }, + ], + (item) => item.value, + pickerOpen, + ); const pickerSearch = pickerQuery.trim(); const filteredPickerItems = pickerSearch.length === 0 @@ -225,6 +236,9 @@ export function DraftHeroHeadline({ itemToStringLabel={(item) => item.label} isItemEqualToValue={(a, b) => a.value === b.value} open={pickerOpen} + onItemHighlighted={(item) => { + highlightedPickerValueRef.current = item?.value ?? null; + }} onOpenChange={(open) => { setPickerOpen(open); setPickerQuery(""); @@ -269,6 +283,16 @@ export function DraftHeroHeadline({ placeholder="Search projects..." value={pickerQuery} onChange={(event) => setPickerQuery(event.target.value)} + onKeyDown={(event) => { + const highlighted = highlightedPickerValueRef.current; + const group = highlighted ? projectEntryByKey.get(highlighted)?.group : undefined; + if (!group || !canPinProjectGroup(group) || !isProjectPinShortcut(event, keybindings)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleProjectGroupPin(group); + }} /> No matching projects. @@ -294,6 +318,14 @@ export function DraftHeroHeadline({ machineByEnvironmentId={environmentMachineById} /> ) : null} + {entry && canPinProjectGroup(entry.group) ? ( + toggleProjectGroupPin(entry.group)} + /> + ) : null} ); }} diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 2e033c6caa8da..39a46d2564901 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -1,4 +1,5 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { ZapIcon } from "lucide-react"; import type { ComposerControlSize } from "./ComposerControl"; @@ -84,3 +85,21 @@ export function EffortGlyph(props: { count: number; filled: number; size?: Compo ); } + +/** One bolt for Fast. Ultrafast adds an outlined echo behind it, cut away by the surface color. */ +export function FastBolt(props: { ultra: boolean }) { + if (!props.ultra) { + return ( +