From 217f0442b8c50ac183c1176a120eeea6d51a4702 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 23:50:20 -0700 Subject: [PATCH 01/18] Persist worktree terminal launch context - keep terminal drawer attached to the created worktree for first-send draft setup scripts - flush local thread and draft state updates before opening terminals --- apps/web/src/components/ChatView.browser.tsx | 136 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 82 ++++++++++- 2 files changed, 213 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..f4fe76933a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -30,6 +30,7 @@ import { import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { useTerminalStateStore } from "../terminalStateStore"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -1517,6 +1518,141 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps first-send worktree setup scripts on the worktree terminal for local drafts", async () => { + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + }); + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const worktreeBranch = "t3code/abcd1234"; + const worktreePath = "/repo/worktrees/t3code-abcd1234"; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitCreateWorktree) { + return { + worktree: { + branch: worktreeBranch, + path: worktreePath, + }, + }; + } + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + const createWorktreeRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.gitCreateWorktree, + ); + expect(createWorktreeRequest).toMatchObject({ + _tag: WS_METHODS.gitCreateWorktree, + cwd: "/repo/project", + branch: "main", + newBranch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftThreadsByThreadId[THREAD_ID]).toMatchObject({ + branch: worktreeBranch, + worktreePath, + envMode: "worktree", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const openRequests = wsRequests.filter( + (request) => request._tag === WS_METHODS.terminalOpen && request.threadId === THREAD_ID, + ); + expect(openRequests.length).toBeGreaterThan(0); + expect(openRequests.every((request) => request.cwd === worktreePath)).toBe(true); + expect( + openRequests.every( + (request) => + request.env && + typeof request.env === "object" && + (request.env as Record).T3CODE_WORKTREE_PATH === worktreePath, + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalOpen && + request.threadId === THREAD_ID && + request.cwd === "/repo/project", + ), + ).toBe(false); + + await vi.waitFor( + () => { + const writeRequest = wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalWrite && + request.threadId === THREAD_ID && + request.data === "bun install\r", + ); + expect(writeRequest).toMatchObject({ + _tag: WS_METHODS.terminalWrite, + threadId: THREAD_ID, + data: "bun install\r", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..013860db71 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { flushSync } from "react-dom"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; @@ -245,6 +246,12 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -358,6 +365,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useState(null); const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = useState(null); + const [terminalLaunchContext, setTerminalLaunchContext] = useState( + null, + ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -1151,15 +1161,20 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const activeTerminalLaunchContext = + terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext : null; + const terminalDrawerWorktreePath = + activeTerminalLaunchContext?.worktreePath ?? activeThreadWorktreePath; + const terminalDrawerCwd = activeTerminalLaunchContext?.cwd ?? gitCwd ?? activeProjectCwd; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProjectCwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProjectCwd, }, - worktreePath: activeThreadWorktreePath, + worktreePath: terminalDrawerWorktreePath, }); - }, [activeProjectCwd, activeThreadWorktreePath]); + }, [activeProjectCwd, terminalDrawerWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( @@ -1395,7 +1410,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; + const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + setTerminalLaunchContext({ + threadId: activeThreadId, + cwd: targetCwd, + worktreePath: targetWorktreePath, + }); setTerminalOpen(true); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); @@ -1408,7 +1429,7 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: Parameters[0] = shouldCreateNewTerminal @@ -2151,6 +2172,47 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (draftThread?.envMode ?? "local") : "local"; + useEffect(() => { + if (!activeThreadId) { + setTerminalLaunchContext(null); + return; + } + setTerminalLaunchContext((current) => { + if (!current) return current; + if (current.threadId === activeThreadId) return current; + return null; + }); + }, [activeThreadId]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd) { + return; + } + setTerminalLaunchContext((current) => { + if (!current || current.threadId !== activeThreadId) { + return current; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === current.cwd && + (activeThreadWorktreePath ?? null) === current.worktreePath + ) { + return null; + } + return current; + }); + }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); + + useEffect(() => { + if (terminalState.terminalOpen) { + return; + } + setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); + }, [activeThreadId, terminalState.terminalOpen]); + useEffect(() => { if (phase !== "running") return; const timer = window.setInterval(() => { @@ -2622,7 +2684,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + flushSync(() => { + setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + }); + } else if (isLocalDraftThread) { + flushSync(() => { + setDraftThreadContext(threadIdForSend, { + branch: result.worktree.branch, + worktreePath: result.worktree.path, + envMode: "worktree", + }); + }); } } @@ -4231,7 +4303,7 @@ export default function ChatView({ threadId }: ChatViewProps) { Date: Sun, 29 Mar 2026 01:56:06 -0700 Subject: [PATCH 02/18] Persist setup terminals for PR worktree bootstrap - launch project setup scripts from the server after worktree prep - keep setup terminal state tied to the thread and surface activity events - add coverage for setup runner and bootstrap turn flow --- apps/server/src/git/Layers/GitManager.test.ts | 130 +++++++++- apps/server/src/git/Layers/GitManager.ts | 22 ++ .../Layers/ProjectSetupScriptRunner.test.ts | 166 ++++++++++++ .../Layers/ProjectSetupScriptRunner.ts | 74 ++++++ .../Services/ProjectSetupScriptRunner.ts | 37 +++ apps/server/src/serverLayers.ts | 7 + apps/server/src/wsServer.test.ts | 163 +++++++++++- apps/server/src/wsServer.ts | 177 +++++++++++++ apps/web/src/components/ChatView.browser.tsx | 142 +++------- apps/web/src/components/ChatView.tsx | 242 ++++++------------ .../components/PullRequestThreadDialog.tsx | 6 +- apps/web/src/lib/gitReactQuery.ts | 13 +- apps/web/src/projectScripts.ts | 20 +- apps/web/src/routes/__root.tsx | 12 + apps/web/src/terminalStateStore.test.ts | 22 +- apps/web/src/terminalStateStore.ts | 85 +++++- packages/contracts/src/git.ts | 3 +- packages/contracts/src/orchestration.test.ts | 41 +++ packages/contracts/src/orchestration.ts | 27 ++ packages/contracts/src/ws.test.ts | 4 + packages/shared/package.json | 4 + packages/shared/src/projectScripts.ts | 37 +++ 22 files changed, 1151 insertions(+), 283 deletions(-) create mode 100644 apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts create mode 100644 apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts create mode 100644 apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts create mode 100644 packages/shared/src/projectScripts.ts diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index db82ea4c72..95d8c4b124 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -6,7 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; -import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; +import type { + GitActionProgressEvent, + GitPreparePullRequestThreadInput, + ModelSelection, + ThreadId, +} from "@t3tools/contracts"; import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; @@ -21,6 +26,11 @@ import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, + type ProjectSetupScriptRunnerShape, +} from "../../projectScripts/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -496,7 +506,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe function preparePullRequestThread( manager: GitManagerShape, - input: { cwd: string; reference: string; mode: "local" | "worktree" }, + input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); } @@ -504,6 +514,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + setupScriptRunner?: ProjectSetupScriptRunnerShape; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -521,6 +532,12 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), + Layer.succeed( + ProjectSetupScriptRunner, + input?.setupScriptRunner ?? { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }, + ), gitCoreLayer, serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -531,6 +548,8 @@ function makeManager(input?: { ); } +const asThreadId = (threadId: string) => threadId as ThreadId; + const GitManagerTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(NodeServices.layer), @@ -1560,6 +1579,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("launches setup only when creating a new PR worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); + fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + yield* runGit(repoDir, ["add", "setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 177, + title: "Worktree setup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/177", + baseRefName: "main", + headRefName: "feature/pr-worktree-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "177", + mode: "worktree", + threadId: asThreadId("thread-pr-setup"), + }); + + expect(result.worktreePath).not.toBeNull(); + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + }), + ); + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1744,6 +1816,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + const setupCalls: ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -1755,18 +1828,27 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }, }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "78", mode: "worktree", + threadId: asThreadId("thread-pr-existing-worktree"), }); expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( fs.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); + expect(setupCalls).toHaveLength(0); }), ); @@ -1946,6 +2028,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("does not fail PR worktree prep when setup terminal startup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); + fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); + yield* runGit(repoDir, ["add", "setup-failure.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 184, + title: "Setup failure PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/184", + baseRefName: "main", + headRefName: "feature/pr-setup-failure", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: () => Effect.fail(new Error("terminal start failed")), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "184", + mode: "worktree", + threadId: asThreadId("thread-pr-setup-failure"), + }); + + expect(result.branch).toBe("feature/pr-setup-failure"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + }), + ); + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index dc082674b7..2125edc7c8 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -24,6 +24,7 @@ import { import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../../projectScripts/Services/ProjectSetupScriptRunner.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "../Errors.ts"; @@ -365,6 +366,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( @@ -993,6 +995,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { + const maybeRunSetupScript = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + return projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + }; + const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = canonicalizeExistingPath(input.cwd); const pullRequestSummary = yield* gitHubCli.getPullRequest({ @@ -1124,6 +1145,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeRunSetupScript(worktree.worktree.path); return { pullRequest, diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000..8f4a3a9869 --- /dev/null +++ b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,166 @@ +import { Effect, Layer, Stream } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import type { OrchestrationReadModel } from "@t3tools/contracts"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; +import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; + +const emptySnapshot = ( + scripts: OrchestrationReadModel["projects"][number]["scripts"], +): OrchestrationReadModel => + ({ + snapshotSequence: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + projects: [ + { + id: "project-1", + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [], + providerSessions: [], + providerStatuses: [], + pendingApprovals: [], + latestTurnByThreadId: {}, + }) as unknown as OrchestrationReadModel; + +describe("ProjectSetupScriptRunner", () => { + it("returns no-script when no setup script exists", async () => { + const open = vi.fn(); + const write = vi.fn(); + const runner = await Effect.runPromise( + Effect.service(ProjectSetupScriptRunner).pipe( + Effect.provide( + ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + getReadModel: () => Effect.succeed(emptySnapshot([])), + readEvents: () => Stream.empty, + dispatch: () => Effect.die(new Error("unused")), + streamDomainEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open, + write, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + dispose: Effect.void, + }), + ), + ), + ), + ), + ); + + const result = await Effect.runPromise( + runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }), + ); + + expect(result).toEqual({ status: "no-script" }); + expect(open).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("opens the deterministic setup terminal with worktree env and writes the command", async () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const runner = await Effect.runPromise( + Effect.service(ProjectSetupScriptRunner).pipe( + Effect.provide( + ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + getReadModel: () => + Effect.succeed( + emptySnapshot([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + ), + readEvents: () => Stream.empty, + dispatch: () => Effect.die(new Error("unused")), + streamDomainEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open, + write, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + dispose: Effect.void, + }), + ), + ), + ), + ), + ); + + const result = await Effect.runPromise( + runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }), + ); + + expect(result).toEqual({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + data: "bun install\r", + }); + }); +}); diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts new file mode 100644 index 0000000000..c3ac77fe52 --- /dev/null +++ b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts @@ -0,0 +1,74 @@ +import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { Effect, Layer } from "effect"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { + type ProjectSetupScriptRunnerShape, + ProjectSetupScriptRunner, +} from "../Services/ProjectSetupScriptRunner.ts"; + +const makeProjectSetupScriptRunner = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const terminalManager = yield* TerminalManager; + + const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => + Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const project = + (input.projectId + ? readModel.projects.find((entry) => entry.id === input.projectId) + : null) ?? + (input.projectCwd + ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd) + : null) ?? + null; + + if (!project) { + return yield* Effect.fail(new Error("Project was not found for setup script execution.")); + } + + const script = setupProjectScript(project.scripts); + if (!script) { + return { + status: "no-script", + } as const; + } + + const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const cwd = input.worktreePath; + const env = projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + }); + + yield* terminalManager.open({ + threadId: input.threadId, + terminalId, + cwd, + env, + }); + yield* terminalManager.write({ + threadId: input.threadId, + terminalId, + data: `${script.command}\r`, + }); + + return { + status: "started", + scriptId: script.id, + scriptName: script.name, + terminalId, + cwd, + } as const; + }); + + return { + runForThread, + } satisfies ProjectSetupScriptRunnerShape; +}); + +export const ProjectSetupScriptRunnerLive = Layer.effect( + ProjectSetupScriptRunner, + makeProjectSetupScriptRunner, +); diff --git a/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts b/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts new file mode 100644 index 0000000000..efee54ae56 --- /dev/null +++ b/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts @@ -0,0 +1,37 @@ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ProjectSetupScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectSetupScriptRunnerResultStarted { + readonly status: "started"; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + readonly cwd: string; +} + +export type ProjectSetupScriptRunnerResult = + | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultStarted; + +export interface ProjectSetupScriptRunnerInput { + readonly threadId: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; +} + +export interface ProjectSetupScriptRunnerShape { + readonly runForThread: ( + input: ProjectSetupScriptRunnerInput, + ) => Effect.Effect; +} + +export class ProjectSetupScriptRunner extends ServiceMap.Service< + ProjectSetupScriptRunner, + ProjectSetupScriptRunnerShape +>()("t3/projectScripts/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7f..27773ad9d8 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -32,6 +32,7 @@ import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; +import { ProjectSetupScriptRunnerLive } from "./projectScripts/Layers/ProjectSetupScriptRunner"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -129,17 +130,23 @@ export function makeServerRuntimeServicesLayer() { ); const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); + const projectSetupScriptRunnerLayer = ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(terminalLayer), + ); const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(projectSetupScriptRunnerLayer), ); return Layer.mergeAll( orchestrationReactorLayer, GitCoreLive, gitManagerLayer, + projectSetupScriptRunnerLayer, terminalLayer, KeybindingsLive, ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 826b9ad6fd..c4ceea682c 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -56,6 +56,10 @@ import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { ServerSettingsService } from "./serverSettings.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerShape, +} from "./projectScripts/Services/ProjectSetupScriptRunner.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -500,7 +504,8 @@ describe("WebSocket Server", () => { providerRegistry?: ProviderRegistryShape; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick; + gitCore?: Partial; + projectSetupScriptRunner?: ProjectSetupScriptRunnerShape; terminalManager?: TerminalManagerShape; serverSettings?: Partial; } = {}, @@ -540,6 +545,9 @@ describe("WebSocket Server", () => { options.gitCore ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) : Layer.empty, + options.projectSetupScriptRunner + ? Layer.succeed(ProjectSetupScriptRunner, options.projectSetupScriptRunner) + : Layer.empty, options.terminalManager ? Layer.succeed(TerminalManager, options.terminalManager) : Layer.empty, @@ -1381,6 +1389,159 @@ describe("WebSocket Server", () => { expect(domainEvent.payload.text).toBe("hello from runtime"); }); + it("bootstraps first-send worktree turns on the server before dispatching turn start", async () => { + const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const providerService: ProviderServiceShape = { + startSession: (threadId) => + Effect.succeed({ + provider: "codex", + status: "ready", + runtimeMode: "full-access", + threadId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + sendTurn: ({ threadId }) => + Effect.succeed({ + threadId, + turnId: asTurnId("provider-turn-bootstrap"), + }), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession: () => unsupported(), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + rollbackConversation: () => unsupported(), + streamEvents: Stream.empty, + }; + const createWorktree = vi.fn(() => + Effect.succeed({ + worktree: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const runForThread = vi.fn(() => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + }), + ); + + server = await createTestServer({ + cwd: "/test", + providerLayer: Layer.succeed(ProviderService, providerService), + gitCore: { createWorktree }, + projectSetupScriptRunner: { runForThread }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const workspaceRoot = makeTempDir("t3code-ws-bootstrap-project-"); + const createdAt = new Date().toISOString(); + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-bootstrap-project-create", + projectId: "project-bootstrap", + title: "Bootstrap Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + + const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.turn.start", + commandId: "cmd-bootstrap-turn-start", + threadId: "thread-bootstrap", + message: { + messageId: "msg-bootstrap", + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: "project-bootstrap", + title: "Bootstrap Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: workspaceRoot, + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: true, + }, + createdAt, + }); + expect(startTurnResponse.error).toBeUndefined(); + expect(createWorktree).toHaveBeenCalledWith({ + cwd: workspaceRoot, + branch: "main", + newBranch: "t3code/bootstrap-branch", + path: null, + }); + expect(runForThread).toHaveBeenCalledWith({ + threadId: "thread-bootstrap", + projectId: "project-bootstrap", + projectCwd: workspaceRoot, + worktreePath: "/tmp/bootstrap-worktree", + }); + + const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); + expect(snapshotResponse.error).toBeUndefined(); + const snapshot = snapshotResponse.result as { threads: unknown[] }; + expect(snapshot.threads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thread-bootstrap", + branch: "t3code/bootstrap-branch", + worktreePath: "/tmp/bootstrap-worktree", + messages: expect.arrayContaining([ + expect.objectContaining({ + id: "msg-bootstrap", + text: "hello", + }), + ]), + activities: expect.arrayContaining([ + expect.objectContaining({ + kind: "setup-script.requested", + }), + expect.objectContaining({ + kind: "setup-script.started", + }), + ]), + }), + ]), + ); + }); + it("routes terminal RPC methods and broadcasts terminal events", async () => { const cwd = makeTempDir("t3code-ws-terminal-cwd-"); const terminalManager = new MockTerminalManager(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index c04d913d52..ccd2b3b92b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -13,6 +13,7 @@ import Mime from "@effect/platform-node/Mime"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, type ClientOrchestrationCommand, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, @@ -76,6 +77,7 @@ import { import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; +import { ProjectSetupScriptRunner } from "./projectScripts/Services/ProjectSetupScriptRunner.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; @@ -216,6 +218,7 @@ export type ServerRuntimeServices = | GitManager | GitCore | TerminalManager + | ProjectSetupScriptRunner | Keybindings | ServerSettingsService | Open @@ -259,6 +262,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const keybindingsManager = yield* Keybindings; const serverSettingsManager = yield* ServerSettingsService; const providerRegistry = yield* ProviderRegistry; @@ -616,6 +620,176 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; const { openInEditor } = yield* Open; + const serverCommandId = (tag: string) => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + + const appendSetupScriptActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("setup-script-activity"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + + const toBootstrapRouteRequestError = (error: unknown) => + Schema.is(RouteRequestError)(error) + ? error + : new RouteRequestError({ + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + }); + + const dispatchBootstrapTurnStart = Effect.fnUntraced(function* ( + command: Extract, + ) { + const bootstrap = command.bootstrap; + if (!bootstrap) { + return yield* orchestrationEngine.dispatch(command); + } + + const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; + let createdThread = false; + let targetProjectId = bootstrap.createThread?.projectId; + let targetProjectCwd = bootstrap.prepareWorktree?.projectCwd; + let targetBranch = bootstrap.createThread?.branch ?? null; + let targetWorktreePath = bootstrap.createThread?.worktreePath ?? null; + + const cleanupCreatedThread = () => + createdThread + ? orchestrationEngine + .dispatch({ + type: "thread.delete", + commandId: serverCommandId("bootstrap-thread-delete"), + threadId: command.threadId, + }) + .pipe(Effect.ignoreCause({ log: true })) + : Effect.void; + + const runSetupProgram = () => + bootstrap.runSetupScript && targetWorktreePath + ? (() => { + const requestedAt = new Date().toISOString(); + return projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath: targetWorktreePath, + }) + .pipe( + Effect.tap((setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + const payload = { + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + worktreePath: targetWorktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe(Effect.asVoid); + }), + Effect.catch((error) => + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: requestedAt, + payload: { + detail: error instanceof Error ? error.message : "Unknown setup failure.", + worktreePath: targetWorktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => Effect.fail(toBootstrapRouteRequestError(error))), + ), + ), + ); + })() + : Effect.void; + + const bootstrapProgram = Effect.gen(function* () { + if (bootstrap.createThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + } + + if (bootstrap.prepareWorktree) { + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + newBranch: bootstrap.prepareWorktree.branch, + path: null, + }); + targetProjectCwd = bootstrap.prepareWorktree.projectCwd; + targetBranch = worktree.worktree.branch; + targetWorktreePath = worktree.worktree.path; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: targetBranch, + worktreePath: targetWorktreePath, + }); + } + + yield* runSetupProgram(); + + return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + }).pipe(Effect.mapError(toBootstrapRouteRequestError)); + + return yield* bootstrapProgram.pipe( + Effect.catch((error) => + cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(error))), + ), + ); + }); const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -741,6 +915,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case ORCHESTRATION_WS_METHODS.dispatchCommand: { const { command } = request.body; const normalizedCommand = yield* normalizeDispatchCommand({ command }); + if (normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap) { + return yield* dispatchBootstrapTurnStart(normalizedCommand); + } return yield* orchestrationEngine.dispatch(normalizedCommand); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f4fe76933a..cb9ea54f51 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1370,7 +1370,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("runs setup scripts after preparing a pull request worktree thread", async () => { + it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { @@ -1475,50 +1475,24 @@ describe("ChatView timeline estimator parity (full app)", () => { cwd: "/repo/project", reference: "1359", mode: "worktree", + threadId: THREAD_ID, }); }, { timeout: 8_000, interval: 16 }, ); - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359", - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: expect.any(String), - cwd: "/repo/worktrees/pr-1359", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const writeRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: expect.any(String), - data: "bun install\r", - }); - }, - { timeout: 8_000, interval: 16 }, - ); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", + ), + ).toBe(false); } finally { await mounted.cleanup(); } }); - it("keeps first-send worktree setup scripts on the worktree terminal for local drafts", async () => { + it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { useTerminalStateStore.setState({ terminalStateByThreadId: {}, }); @@ -1539,8 +1513,6 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }); - const worktreeBranch = "t3code/abcd1234"; - const worktreePath = "/repo/worktrees/t3code-abcd1234"; const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ @@ -1553,14 +1525,6 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ]), resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitCreateWorktree) { - return { - worktree: { - branch: worktreeBranch, - path: worktreePath, - }, - }; - } if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { return { sequence: fixture.snapshot.snapshotSequence + 1, @@ -1580,74 +1544,50 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const createWorktreeRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.gitCreateWorktree, - ); - expect(createWorktreeRequest).toMatchObject({ - _tag: WS_METHODS.gitCreateWorktree, - cwd: "/repo/project", - branch: "main", - newBranch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadId[THREAD_ID]).toMatchObject({ - branch: worktreeBranch, - worktreePath, - envMode: "worktree", + const dispatchRequest = wsRequests.find( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ) as + | { + _tag: string; + command?: { + type?: string; + bootstrap?: { + createThread?: { projectId?: string }; + prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; + runSetupScript?: boolean; + }; + }; + } + | undefined; + expect(dispatchRequest?.command).toMatchObject({ + type: "thread.turn.start", + bootstrap: { + createThread: { + projectId: PROJECT_ID, + }, + prepareWorktree: { + projectCwd: "/repo/project", + baseBranch: "main", + branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), + }, + runSetupScript: true, + }, }); }, { timeout: 8_000, interval: 16 }, ); - await vi.waitFor( - () => { - const openRequests = wsRequests.filter( - (request) => request._tag === WS_METHODS.terminalOpen && request.threadId === THREAD_ID, - ); - expect(openRequests.length).toBeGreaterThan(0); - expect(openRequests.every((request) => request.cwd === worktreePath)).toBe(true); - expect( - openRequests.every( - (request) => - request.env && - typeof request.env === "object" && - (request.env as Record).T3CODE_WORKTREE_PATH === worktreePath, - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, + expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( + false, ); - expect( wsRequests.some( (request) => - request._tag === WS_METHODS.terminalOpen && + request._tag === WS_METHODS.terminalWrite && request.threadId === THREAD_ID && - request.cwd === "/repo/project", + request.data === "bun install\r", ), ).toBe(false); - - await vi.waitFor( - () => { - const writeRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalWrite && - request.threadId === THREAD_ID && - request.data === "bun install\r", - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: THREAD_ID, - data: "bun install\r", - }); - }, - { timeout: 8_000, interval: 16 }, - ); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 013860db71..6dc1b50e31 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,11 +24,10 @@ import { import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { flushSync } from "react-dom"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; @@ -111,7 +110,6 @@ import { projectScriptCwd, projectScriptRuntimeEnv, projectScriptIdFromCommand, - setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; @@ -240,12 +238,6 @@ interface ChatViewProps { threadId: ThreadId; } -interface PendingPullRequestSetupRequest { - threadId: ThreadId; - worktreePath: string; - scriptId: string; -} - interface TerminalLaunchContext { threadId: ThreadId; cwd: string; @@ -258,7 +250,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const markThreadVisited = useStore((store) => store.markThreadVisited); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); - const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -271,7 +262,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; @@ -363,8 +353,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = - useState(null); const [terminalLaunchContext, setTerminalLaunchContext] = useState( null, ); @@ -422,6 +410,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const storeServerTerminalLaunchContext = useTerminalStateStore( + (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + ); + const storeClearTerminalLaunchContext = useTerminalStateStore( + (s) => s.clearTerminalLaunchContext, + ); const setPrompt = useCallback( (nextPrompt: string) => { @@ -584,24 +578,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const handlePreparedPullRequestThread = useCallback( async (input: { branch: string; worktreePath: string | null }) => { - const targetThreadId = await openOrReuseProjectDraftThread({ + await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", }); - const setupScript = - input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null; - if (targetThreadId && input.worktreePath && setupScript) { - setPendingPullRequestSetupRequest({ - threadId: targetThreadId, - worktreePath: input.worktreePath, - scriptId: setupScript.id, - }); - } else { - setPendingPullRequestSetupRequest(null); - } }, - [activeProject, openOrReuseProjectDraftThread], + [openOrReuseProjectDraftThread], ); useEffect(() => { @@ -1162,9 +1145,19 @@ export default function ChatView({ threadId }: ChatViewProps) { const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeTerminalLaunchContext = - terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext : null; + terminalLaunchContext?.threadId === activeThreadId + ? terminalLaunchContext + : (storeServerTerminalLaunchContext ?? null); + const setupTerminalWorktreeFallback = + activeTerminalLaunchContext && + activeTerminalLaunchContext.worktreePath === null && + terminalState.activeTerminalId.startsWith("setup-") + ? activeTerminalLaunchContext.cwd + : null; const terminalDrawerWorktreePath = - activeTerminalLaunchContext?.worktreePath ?? activeThreadWorktreePath; + activeTerminalLaunchContext?.worktreePath ?? + setupTerminalWorktreeFallback ?? + activeThreadWorktreePath; const terminalDrawerCwd = activeTerminalLaunchContext?.cwd ?? gitCwd ?? activeProjectCwd; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProjectCwd) return {}; @@ -1478,44 +1471,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); - useEffect(() => { - if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) { - return; - } - if (pendingPullRequestSetupRequest.threadId !== activeThreadId) { - return; - } - if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) { - return; - } - - const setupScript = - activeProject.scripts.find( - (script) => script.id === pendingPullRequestSetupRequest.scriptId, - ) ?? null; - setPendingPullRequestSetupRequest(null); - if (!setupScript) { - return; - } - - void runProjectScript(setupScript, { - cwd: pendingPullRequestSetupRequest.worktreePath, - worktreePath: pendingPullRequestSetupRequest.worktreePath, - rememberAsLastInvoked: false, - }).catch((error) => { - toastManager.add({ - type: "error", - title: "Failed to run setup script.", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }); - }, [ - activeProject, - activeThread, - activeThreadId, - pendingPullRequestSetupRequest, - runProjectScript, - ]); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -2175,6 +2130,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); + storeClearTerminalLaunchContext(threadId); return; } setTerminalLaunchContext((current) => { @@ -2182,7 +2138,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (current.threadId === activeThreadId) return current; return null; }); - }, [activeThreadId]); + }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { @@ -2200,18 +2156,44 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { + storeClearTerminalLaunchContext(activeThreadId); return null; } return current; }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); + }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { + return; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === storeServerTerminalLaunchContext.cwd && + (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath + ) { + storeClearTerminalLaunchContext(activeThreadId); + } + }, [ + activeProjectCwd, + activeThreadId, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + storeServerTerminalLaunchContext, + ]); useEffect(() => { if (terminalState.terminalOpen) { return; } + if (activeThreadId) { + storeClearTerminalLaunchContext(activeThreadId); + } setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [activeThreadId, terminalState.terminalOpen]); + }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); useEffect(() => { if (phase !== "running") return; @@ -2658,46 +2640,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); - let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; - let nextThreadBranch = activeThread.branch; - let nextThreadWorktreePath = activeThread.worktreePath; await (async () => { - // On first message: lock in branch + create worktree if needed. - if (baseBranchForWorktree) { - beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); - const result = await createWorktreeMutation.mutateAsync({ - cwd: activeProject.cwd, - branch: baseBranchForWorktree, - newBranch, - }); - nextThreadBranch = result.worktree.branch; - nextThreadWorktreePath = result.worktree.path; - if (isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); - // Keep local thread state in sync immediately so terminal drawer opens - // with the worktree cwd/env instead of briefly using the project root. - flushSync(() => { - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); - }); - } else if (isLocalDraftThread) { - flushSync(() => { - setDraftThreadContext(threadIdForSend, { - branch: result.worktree.branch, - worktreePath: result.worktree.path, - envMode: "worktree", - }); - }); - } - } - let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; @@ -2725,48 +2669,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), }; - if (isLocalDraftThread) { - await api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: threadIdForSend, - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, - }); - createdServerThreadForLocalDraft = true; - } - - let setupScript: ProjectScript | null = null; - if (baseBranchForWorktree) { - setupScript = setupProjectScript(activeProject.scripts); - } - if (setupScript) { - let shouldRunSetupScript = false; - if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } - if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); - } - } - // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ @@ -2789,6 +2691,35 @@ export default function ChatView({ threadId }: ChatViewProps) { beginSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.cwd, + baseBranch: baseBranchForWorktree, + branch: buildTemporaryWorktreeBranchName(), + }, + runSetupScript: true, + } + : {}), + } + : undefined; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -2803,19 +2734,11 @@ export default function ChatView({ threadId }: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode, + ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { - if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); - } if ( !turnStartSucceeded && promptRef.current.length === 0 && @@ -4261,6 +4184,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 2a69facd10..8fa899343e 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { GitResolvePullRequestResult } from "@t3tools/contracts"; +import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; + threadId: ThreadId; cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; @@ -32,6 +33,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, + threadId, cwd, initialReference, onOpenChange, @@ -130,6 +132,7 @@ export function PullRequestThreadDialog({ const result = await preparePullRequestThreadMutation.mutateAsync({ reference: parsedReference, mode, + ...(mode === "worktree" ? { threadId } : {}), }); await onPrepared({ branch: result.branch, @@ -147,6 +150,7 @@ export function PullRequestThreadDialog({ parsedReference, preparePullRequestThreadMutation, resolvedPullRequest, + threadId, ], ); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index cfa2c72f74..b7633c4e27 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import { type GitStackedAction } from "@t3tools/contracts"; +import { type GitStackedAction, type ThreadId } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -202,13 +202,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => { + mutationFn: async ({ + reference, + mode, + threadId, + }: { + reference: string; + mode: "local" | "worktree"; + threadId?: ThreadId; + }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, reference, mode, + ...(threadId ? { threadId } : {}), }); }, mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index c11c3923bc..a1826971b0 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -4,6 +4,11 @@ import { type KeybindingCommand, type ProjectScript, } from "@t3tools/contracts"; +import { + projectScriptCwd as sharedProjectScriptCwd, + projectScriptRuntimeEnv as sharedProjectScriptRuntimeEnv, + setupProjectScript as sharedSetupProjectScript, +} from "@t3tools/shared/projectScripts"; import { Schema } from "effect"; function normalizeScriptId(value: string): string { @@ -69,22 +74,13 @@ export function projectScriptCwd(input: { }; worktreePath?: string | null; }): string { - return input.worktreePath ?? input.project.cwd; + return sharedProjectScriptCwd(input); } export function projectScriptRuntimeEnv( input: ProjectScriptRuntimeEnvInput, ): Record { - const env: Record = { - T3CODE_PROJECT_ROOT: input.project.cwd, - }; - if (input.worktreePath) { - env.T3CODE_WORKTREE_PATH = input.worktreePath; - } - if (input.extraEnv) { - return { ...env, ...input.extraEnv }; - } - return env; + return sharedProjectScriptRuntimeEnv(input); } export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { @@ -93,5 +89,5 @@ export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | } export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null { - return scripts.find((script) => script.runOnWorktreeCreate) ?? null; + return sharedSetupProjectScript(scripts); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e99ec50226..59308fad2b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -140,6 +140,8 @@ function EventRouter() { const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); + const ensureTerminal = useTerminalStateStore((store) => store.ensureTerminal); + const setTerminalLaunchContext = useTerminalStateStore((store) => store.setTerminalLaunchContext); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); @@ -221,6 +223,14 @@ function EventRouter() { domainEventFlushThrottler.maybeExecute(); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { + if (event.type === "started" || event.type === "restarted") { + const threadId = ThreadId.makeUnsafe(event.threadId); + ensureTerminal(threadId, event.terminalId, { open: true, active: true }); + setTerminalLaunchContext(threadId, { + cwd: event.snapshot.cwd, + worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, + }); + } const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess === null) { return; @@ -327,10 +337,12 @@ function EventRouter() { unsubProvidersUpdated(); }; }, [ + ensureTerminal, navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, + setTerminalLaunchContext, syncServerReadModel, ]); diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index d618275682..4e8b84963b 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -8,7 +8,10 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1"); describe("terminalStateStore actions", () => { beforeEach(() => { useTerminalStateStore.persist.clearStorage(); - useTerminalStateStore.setState({ terminalStateByThreadId: {} }); + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, + }); }); it("returns a closed default terminal state for unknown threads", () => { @@ -82,6 +85,23 @@ describe("terminalStateStore actions", () => { ]); }); + it("ensures unknown server terminals are registered, opened, and activated", () => { + const store = useTerminalStateStore.getState(); + store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true }); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalOpen).toBe(true); + expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); + expect(terminalState.activeTerminalId).toBe("setup-setup"); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default"] }, + { id: "group-setup-setup", terminalIds: ["setup-setup"] }, + ]); + }); + it("allows unlimited groups while keeping each group capped at four terminals", () => { const store = useTerminalStateStore.getState(); store.splitTerminal(THREAD_ID, "terminal-2"); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 4f51e2ed8d..369c18631f 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -26,6 +26,11 @@ interface ThreadTerminalState { activeTerminalGroupId: string; } +export interface ThreadTerminalLaunchContext { + cwd: string; + worktreePath: string | null; +} + const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; function createTerminalStateStorage() { @@ -473,12 +478,20 @@ function updateTerminalStateByThreadId( interface TerminalStateStoreState { terminalStateByThreadId: Record; + terminalLaunchContextByThreadId: Record; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; newTerminal: (threadId: ThreadId, terminalId: string) => void; + ensureTerminal: ( + threadId: ThreadId, + terminalId: string, + options?: { open?: boolean; active?: boolean }, + ) => void; setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; closeTerminal: (threadId: ThreadId, terminalId: string) => void; + setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; + clearTerminalLaunchContext: (threadId: ThreadId) => void; setTerminalActivity: ( threadId: ThreadId, terminalId: string, @@ -512,6 +525,7 @@ export const useTerminalStateStore = create()( return { terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, setTerminalOpen: (threadId, open) => updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => @@ -520,27 +534,92 @@ export const useTerminalStateStore = create()( updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), newTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), + ensureTerminal: (threadId, terminalId, options) => + updateTerminal(threadId, (state) => { + let nextState = state; + if (!state.terminalIds.includes(terminalId)) { + nextState = newThreadTerminal(nextState, terminalId); + } + if (options?.active === false) { + nextState = { + ...nextState, + activeTerminalId: state.activeTerminalId, + activeTerminalGroupId: state.activeTerminalGroupId, + }; + } + if (options?.active ?? true) { + nextState = setThreadActiveTerminal(nextState, terminalId); + } + if (options?.open) { + nextState = setThreadTerminalOpen(nextState, true); + } + return normalizeThreadTerminalState(nextState); + }), setActiveTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), + setTerminalLaunchContext: (threadId, context) => + set((state) => ({ + terminalLaunchContextByThreadId: { + ...state.terminalLaunchContextByThreadId, + [threadId]: context, + }, + })), + clearTerminalLaunchContext: (threadId) => + set((state) => { + if (!state.terminalLaunchContextByThreadId[threadId]) { + return state; + } + const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; + return { terminalLaunchContextByThreadId: rest }; + }), setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => updateTerminal(threadId, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), clearTerminalState: (threadId) => - updateTerminal(threadId, () => createDefaultThreadTerminalState()), + set((state) => { + const nextTerminalStateByThreadId = updateTerminalStateByThreadId( + state.terminalStateByThreadId, + threadId, + () => createDefaultThreadTerminalState(), + ); + const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; + const { [threadId]: _removed, ...remainingLaunchContexts } = + state.terminalLaunchContextByThreadId; + if ( + nextTerminalStateByThreadId === state.terminalStateByThreadId && + !hadLaunchContext + ) { + return state; + } + return { + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalLaunchContextByThreadId: remainingLaunchContexts, + }; + }), removeOrphanedTerminalStates: (activeThreadIds) => set((state) => { const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( (id) => !activeThreadIds.has(id as ThreadId), ); - if (orphanedIds.length === 0) return state; + const orphanedLaunchContextIds = Object.keys( + state.terminalLaunchContextByThreadId, + ).filter((id) => !activeThreadIds.has(id as ThreadId)); + if (orphanedIds.length === 0 && orphanedLaunchContextIds.length === 0) return state; const next = { ...state.terminalStateByThreadId }; for (const id of orphanedIds) { delete next[id as ThreadId]; } - return { terminalStateByThreadId: next }; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; + for (const id of orphanedLaunchContextIds) { + delete nextLaunchContexts[id as ThreadId]; + } + return { + terminalStateByThreadId: next, + terminalLaunchContextByThreadId: nextLaunchContexts, + }; }), }; }, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index f8b65abf2c..04144c7890 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -105,6 +105,7 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, mode: GitPreparePullRequestThreadMode, + threadId: Schema.optional(ThreadId), }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 06bb35038d..53e84f1b98 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -195,6 +195,47 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( }), ); +it.effect("accepts bootstrap metadata in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-bootstrap", + threadId: "thread-1", + message: { + messageId: "msg-bootstrap", + role: "user", + text: "hello", + attachments: [], + }, + bootstrap: { + createThread: { + projectId: "project-1", + title: "Bootstrap thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + prepareWorktree: { + projectCwd: "/tmp/workspace", + baseBranch: "main", + branch: "t3code/example", + }, + runSetupScript: true, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); + assert.strictEqual(parsed.bootstrap?.runSetupScript, true); + }), +); + it.effect("decodes thread.created runtime mode for historical events", () => Effect.gen(function* () { const parsed = yield* decodeThreadCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..45ade09c47 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -388,6 +388,31 @@ const ThreadInteractionModeSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, +}); + +const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ + projectCwd: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + branch: Schema.optional(TrimmedNonEmptyString), +}); + +const ThreadTurnStartBootstrap = Schema.Struct({ + createThread: Schema.optional(ThreadTurnStartBootstrapCreateThread), + prepareWorktree: Schema.optional(ThreadTurnStartBootstrapPrepareWorktree), + runSetupScript: Schema.optional(Schema.Boolean), +}); + +export type ThreadTurnStartBootstrap = typeof ThreadTurnStartBootstrap.Type; + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -404,6 +429,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), + bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); @@ -422,6 +448,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 0d8d4dbec2..69b9202d0b 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -67,9 +67,13 @@ it.effect("accepts git.preparePullRequestThread requests", () => cwd: "/repo", reference: "#42", mode: "worktree", + threadId: "thread-1", }, }); assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); + if (parsed.body._tag === WS_METHODS.gitPreparePullRequestThread) { + assert.strictEqual(parsed.body.threadId, "thread-1"); + } }), ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 40ffbf35c2..bb10a6ceec 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,10 @@ "./String": { "types": "./src/String.ts", "import": "./src/String.ts" + }, + "./projectScripts": { + "types": "./src/projectScripts.ts", + "import": "./src/projectScripts.ts" } }, "scripts": { diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts new file mode 100644 index 0000000000..199a55bf3c --- /dev/null +++ b/packages/shared/src/projectScripts.ts @@ -0,0 +1,37 @@ +import type { ProjectScript } from "@t3tools/contracts"; + +interface ProjectScriptRuntimeEnvInput { + project: { + cwd: string; + }; + worktreePath?: string | null; + extraEnv?: Record; +} + +export function projectScriptCwd(input: { + project: { + cwd: string; + }; + worktreePath?: string | null; +}): string { + return input.worktreePath ?? input.project.cwd; +} + +export function projectScriptRuntimeEnv( + input: ProjectScriptRuntimeEnvInput, +): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.project.cwd, + }; + if (input.worktreePath) { + env.T3CODE_WORKTREE_PATH = input.worktreePath; + } + if (input.extraEnv) { + return { ...env, ...input.extraEnv }; + } + return env; +} + +export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { + return scripts.find((script) => script.runOnWorktreeCreate) ?? null; +} From 033be3036e5d1e6887e206207c55ee1969818075 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 10:26:57 -0700 Subject: [PATCH 03/18] Fix ProjectSetupScriptRunner test after main merge Co-authored-by: codex --- .../src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts index 8f4a3a9869..7d8026eec3 100644 --- a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts @@ -57,7 +57,6 @@ describe("ProjectSetupScriptRunner", () => { restart: () => Effect.die(new Error("unused")), close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), - dispose: Effect.void, }), ), ), @@ -125,7 +124,6 @@ describe("ProjectSetupScriptRunner", () => { restart: () => Effect.die(new Error("unused")), close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), - dispose: Effect.void, }), ), ), From e3372a41b64af851a2bbcd757bf5a68608e82466 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 31 Mar 2026 00:56:00 +0000 Subject: [PATCH 04/18] Fix: move preparingWorktree=false after dispatchCommand so indicator renders during server-side worktree creation Applied via @cursor push command --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 64fe294611..3ff5d06238 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2803,7 +2803,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } - beginLocalDispatch({ preparingWorktree: false }); const turnAttachments = await turnAttachmentsPromise; const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -2851,6 +2850,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); + beginLocalDispatch({ preparingWorktree: false }); turnStartSucceeded = true; })().catch(async (err: unknown) => { if ( From d3b8b2ea905cc53ce142dc31ded1fff0648e19b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 20:26:10 -0700 Subject: [PATCH 05/18] Persist script terminal worktree paths - Capture worktree path once for setup script events - Log setup launch failures without masking the original cause - Preserve thread cleanup behavior for non-interrupt failures --- apps/server/src/wsServer.ts | 39 +++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index b5017d67af..8b1f7f08f2 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -637,13 +637,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const runSetupProgram = () => bootstrap.runSetupScript && targetWorktreePath ? (() => { + const worktreePath = targetWorktreePath; const requestedAt = new Date().toISOString(); return projectSetupScriptRunner .runForThread({ threadId: command.threadId, ...(targetProjectId ? { projectId: targetProjectId } : {}), ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), - worktreePath: targetWorktreePath, + worktreePath, }) .pipe( Effect.tap((setupResult) => { @@ -654,7 +655,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< scriptId: setupResult.scriptId, scriptName: setupResult.scriptName, terminalId: setupResult.terminalId, - worktreePath: targetWorktreePath, + worktreePath, }; return Effect.all([ appendSetupScriptActivity({ @@ -675,22 +676,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), ]).pipe(Effect.asVoid); }), - Effect.catch((error) => - appendSetupScriptActivity({ + Effect.catch((error) => { + const detail = + error instanceof Error ? error.message : "Unknown setup failure."; + return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", summary: "Setup script failed to start", createdAt: requestedAt, payload: { - detail: error instanceof Error ? error.message : "Unknown setup failure.", - worktreePath: targetWorktreePath, + detail, + worktreePath, }, tone: "error", }).pipe( Effect.ignoreCause({ log: false }), - Effect.flatMap(() => Effect.fail(toBootstrapRouteRequestError(error))), - ), - ), + Effect.zipRight( + Effect.logWarning( + "bootstrap turn start failed to launch setup script", + { + threadId: command.threadId, + worktreePath, + detail, + }, + ), + ), + ); + }), ); })() : Effect.void; @@ -738,9 +750,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }).pipe(Effect.mapError(toBootstrapRouteRequestError)); return yield* bootstrapProgram.pipe( - Effect.catch((error) => - cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(error))), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return cleanupCreatedThread().pipe(Effect.zipRight(Effect.failCause(cause))); + }), ); }); From 9d56921606caa15e6121784c294f756b63f7cad6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 20:36:18 -0700 Subject: [PATCH 06/18] Fix wsServer bootstrap effect chaining - replace Effect.zipRight with Effect.flatMap in bootstrap cleanup and setup failure handling - keep the current server bootstrap behavior while restoring Effect v4 compatibility Co-authored-by: codex --- apps/server/src/wsServer.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8b1f7f08f2..301e642686 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -677,8 +677,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ]).pipe(Effect.asVoid); }), Effect.catch((error) => { - const detail = - error instanceof Error ? error.message : "Unknown setup failure."; + const detail = error instanceof Error ? error.message : "Unknown setup failure."; return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", @@ -691,15 +690,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< tone: "error", }).pipe( Effect.ignoreCause({ log: false }), - Effect.zipRight( - Effect.logWarning( - "bootstrap turn start failed to launch setup script", - { - threadId: command.threadId, - worktreePath, - detail, - }, - ), + Effect.flatMap(() => + Effect.logWarning("bootstrap turn start failed to launch setup script", { + threadId: command.threadId, + worktreePath, + detail, + }), ), ); }), @@ -754,7 +750,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); } - return cleanupCreatedThread().pipe(Effect.zipRight(Effect.failCause(cause))); + return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.failCause(cause))); }), ); }); From 5d806056148050baedeed5f1cfe736541e20dd80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:43:04 -0700 Subject: [PATCH 07/18] rev --- .../orchestrationEngine.integration.test.ts | 1656 ++++++++--------- .../Layers/CheckpointStore.test.ts | 55 +- apps/server/src/git/Layers/GitManager.test.ts | 314 ++-- apps/server/src/server.ts | 6 +- 4 files changed, 993 insertions(+), 1038 deletions(-) diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index ce5a7839a2..a5cc8f8cc6 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -40,7 +40,6 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -const INTEGRATION_TEST_TIMEOUT_MS = 180_000; type IntegrationProvider = ProviderKind; function nowIso() { @@ -169,91 +168,88 @@ const startTurn = (input: { createdAt: nowIso(), }); -it.live( - "runs a single turn end-to-end and persists checkpoint state in sqlite + git", - () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - const turnResponse: TestTurnResponse = { - events: [ - { - type: "turn.started", - ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Single turn response.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }; +it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession(turnResponse); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-single", - messageId: "msg-user-single", - text: "Say hello", - }); - const finalizedReceipt = yield* harness.waitForReceipt( - (receipt): receipt is CheckpointDiffFinalizedReceipt => - receipt.type === "checkpoint.diff.finalized" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); - if (finalizedReceipt.type !== "checkpoint.diff.finalized") { - throw new Error("Expected checkpoint.diff.finalized receipt."); - } - assert.equal(finalizedReceipt.status, "ready"); - yield* harness.waitForReceipt( - (receipt): receipt is TurnProcessingQuiescedReceipt => - receipt.type === "turn.processing.quiesced" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); - - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.status === "ready" && - entry.messages.some( - (message) => message.role === "assistant" && message.streaming === false, - ) && - entry.checkpoints.length === 1, - ); - assert.equal(thread.checkpoints[0]?.status, "ready"); - assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1); - - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); - assert.equal(checkpointRows.length, 1); - assert.equal(checkpointRows[0]?.checkpointTurnCount, 1); - assert.equal(checkpointRows[0]?.status, "ready"); - assert.deepEqual(checkpointRows[0]?.files, []); - - const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); - const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); - assert.equal(gitRefExists(harness.workspaceDir, ref0), true); - assert.equal(gitRefExists(harness.workspaceDir, ref1), true); - assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); - assert.equal(gitShowFileAtRef(harness.workspaceDir, ref1, "README.md"), "v1\n"); - }), - ), - INTEGRATION_TEST_TIMEOUT_MS, + const turnResponse: TestTurnResponse = { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Single turn response.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }; + + yield* harness.adapterHarness!.queueTurnResponseForNextSession(turnResponse); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-single", + messageId: "msg-user-single", + text: "Say hello", + }); + const finalizedReceipt = yield* harness.waitForReceipt( + (receipt): receipt is CheckpointDiffFinalizedReceipt => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); + if (finalizedReceipt.type !== "checkpoint.diff.finalized") { + throw new Error("Expected checkpoint.diff.finalized receipt."); + } + assert.equal(finalizedReceipt.status, "ready"); + yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.streaming === false, + ) && + entry.checkpoints.length === 1, + ); + assert.equal(thread.checkpoints[0]?.status, "ready"); + assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1); + + const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ + threadId: THREAD_ID, + }); + assert.equal(checkpointRows.length, 1); + assert.equal(checkpointRows[0]?.checkpointTurnCount, 1); + assert.equal(checkpointRows[0]?.status, "ready"); + assert.deepEqual(checkpointRows[0]?.files, []); + + const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); + const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); + assert.equal(gitRefExists(harness.workspaceDir, ref0), true); + assert.equal(gitRefExists(harness.workspaceDir, ref1), true); + assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); + assert.equal(gitShowFileAtRef(harness.workspaceDir, ref1, "README.md"), "v1\n"); + }), + ), ); it.live.skipIf(!process.env.CODEX_BINARY_PATH)( @@ -349,13 +345,567 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( assert.equal(secondThread.session?.threadId, "thread-1"); }), ), - INTEGRATION_TEST_TIMEOUT_MS, +); + +it.live("runs multi-turn file edits and persists checkpoint diffs", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Updated README to v2.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-multi-1", + messageId: "msg-user-multi-1", + text: "Make first edit", + }); + yield* harness.waitForReceipt( + (receipt): receipt is CheckpointDiffFinalizedReceipt => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Updated README to v3.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-multi-2", + messageId: "msg-user-multi-2", + text: "Make second edit", + }); + const secondReceipt = yield* harness.waitForReceipt( + (receipt): receipt is CheckpointDiffFinalizedReceipt => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 2, + ); + if (secondReceipt.type !== "checkpoint.diff.finalized") { + throw new Error("Expected checkpoint.diff.finalized receipt."); + } + assert.equal(secondReceipt.status, "ready"); + yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 2, + ); + + const secondTurnThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), + ); + const secondCheckpoint = secondTurnThread.checkpoints.find( + (checkpoint) => checkpoint.checkpointTurnCount === 2, + ); + assert.equal( + secondCheckpoint?.files.some((file) => file.path === "README.md"), + true, + ); + + const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ + threadId: THREAD_ID, + }); + assert.deepEqual( + checkpointRows.map((row) => row.checkpointTurnCount), + [1, 2], + ); + + const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({ + cwd: harness.workspaceDir, + fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), + toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), + fallbackFromToHead: false, + }); + assert.equal(incrementalDiff.includes("README.md"), true); + + const fullDiff = yield* harness.checkpointStore.diffCheckpoints({ + cwd: harness.workspaceDir, + fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), + toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), + fallbackFromToHead: false, + }); + assert.equal(fullDiff.includes("README.md"), true); + + assert.equal( + gitShowFileAtRef( + harness.workspaceDir, + checkpointRefForThreadTurn(THREAD_ID, 1), + "README.md", + ), + "v2\n", + ); + assert.equal( + gitShowFileAtRef( + harness.workspaceDir, + checkpointRefForThreadTurn(THREAD_ID, 2), + "README.md", + ), + "v3\n", + ); + }), + ), +); + +it.live("tracks approval requests and resolves pending approvals on user response", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve command execution", + }, + { + type: "turn.completed", + ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-approval", + messageId: "msg-user-approval", + text: "Run command needing approval", + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + assert.equal( + thread.activities.some((activity) => activity.kind === "approval.requested"), + true, + ); + + const pendingRow = yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "pending" && row.decision === null, + ); + assert.equal(pendingRow.status, "pending"); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + const resolvedRow = yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + assert.equal(resolvedRow.status, "resolved"); + assert.equal(resolvedRow.decision, "accept"); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "provider approval response", + ); + assert.equal(approvalResponses.length, 1); + assert.equal(approvalResponses[0]?.requestId, "req-approval-1"); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + ), +); + +it.live("records failed turn runtime state and checkpoint status as error", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "content.delta", + ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + streamKind: "assistant_text", + delta: "Partial output before failure.\n", + }, + }, + { + type: "runtime.error", + ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + message: "Sandbox command failed.", + }, + }, + { + type: "turn.completed", + ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + state: "failed", + errorMessage: "Sandbox command failed.", + }, + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-failure", + messageId: "msg-user-failure", + text: "Run risky command", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "error" && + entry.session?.lastError === "Sandbox command failed." && + entry.activities.some((activity) => activity.kind === "runtime.error") && + entry.checkpoints.length === 1, + ); + assert.equal(thread.session?.status, "error"); + assert.equal(thread.checkpoints[0]?.status, "error"); + + const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ + threadId: THREAD_ID, + checkpointTurnCount: 1, + }); + assert.equal(Option.isSome(checkpointRow), true); + if (Option.isSome(checkpointRow)) { + assert.equal(checkpointRow.value.status, "error"); + } + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), + true, + ); + }), + ), +); + +it.live("reverts to an earlier checkpoint and trims checkpoint projections + git refs", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Updated README to v2.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-revert-1", + messageId: "msg-user-revert-1", + text: "First edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, + ); + + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Updated README to v3.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-revert-2", + messageId: "msg-user-revert-2", + text: "Second edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.activities.some((activity) => activity.turnId === "turn-2"), + 8000, + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.deepEqual( + revertedThread.messages.map((message) => ({ role: message.role, text: message.text })), + [ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ], + ); + assert.equal( + revertedThread.activities.some((activity) => activity.turnId === "turn-2"), + false, + ); + assert.equal( + revertedThread.activities.some( + (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started", + ), + true, + ); + assert.equal( + revertedThread.activities.some( + (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed", + ), + true, + ); + assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), + false, + ); + assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + + const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ + threadId: THREAD_ID, + }); + assert.equal(checkpointRows.length, 1); + }), + ), ); it.live( - "runs multi-turn file edits and persists checkpoint diffs", + "appends checkpoint.revert.failed activity when revert is requested without an active session", () => withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), + threadId: THREAD_ID, + turnCount: 0, + createdAt: nowIso(), + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some( + (activity) => + activity.kind === "checkpoint.revert.failed" && + typeof activity.payload === "object" && + activity.payload !== null, + ), + ); + const failureActivity = thread.activities.find( + (activity) => activity.kind === "checkpoint.revert.failed", + ); + assert.equal(failureActivity !== undefined, true); + assert.equal( + String( + (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, + ).includes("No active provider session"), + true, + ); + }), + ), +); + +it.live("starts a claudeAgent session on first turn when provider is requested", () => + withHarness( + (harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -363,183 +913,164 @@ it.live( events: [ { type: "turn.started", - ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), + ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { - type: "tool.started", - ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", + delta: "Claude first turn.\n", }, { - type: "tool.completed", - ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), + type: "turn.completed", + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeAgent" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeAgent"); + }), + "claudeAgent", + ), +); + +it.live("recovers claudeAgent sessions after provider stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", }, { type: "message.delta", - ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - delta: "Updated README to v2.\n", + delta: "Turn before restart.\n", }, { type: "turn.completed", - ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); - }), }); yield* startTurn({ harness, - commandId: "cmd-turn-start-multi-1", - messageId: "msg-user-multi-1", - text: "Make first edit", + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); - yield* harness.waitForReceipt( - (receipt): receipt is CheckpointDiffFinalizedReceipt => - receipt.type === "checkpoint.diff.finalized" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); yield* harness.waitForThread( THREAD_ID, - (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", ); - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + yield* harness.adapterHarness!.adapter.stopAll(); + yield* waitForSync( + () => harness.adapterHarness!.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "provider stopAll", + ); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ events: [ { type: "turn.started", - ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - delta: "Updated README to v3.\n", + delta: "Turn after restart.\n", }, { type: "turn.completed", - ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); - }), }); yield* startTurn({ harness, - commandId: "cmd-turn-start-multi-2", - messageId: "msg-user-multi-2", - text: "Make second edit", + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", }); - const secondReceipt = yield* harness.waitForReceipt( - (receipt): receipt is CheckpointDiffFinalizedReceipt => - receipt.type === "checkpoint.diff.finalized" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 2, - ); - if (secondReceipt.type !== "checkpoint.diff.finalized") { - throw new Error("Expected checkpoint.diff.finalized receipt."); - } - assert.equal(secondReceipt.status, "ready"); - yield* harness.waitForReceipt( - (receipt): receipt is TurnProcessingQuiescedReceipt => - receipt.type === "turn.processing.quiesced" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 2, + yield* waitForSync( + () => harness.adapterHarness!.getStartCount(), + (count) => count === 2, + "claude provider recovery start", ); - const secondTurnThread = yield* harness.waitForThread( + const recoveredThread = yield* harness.waitForThread( THREAD_ID, (entry) => - entry.latestTurn?.turnId === "turn-2" && - entry.checkpoints.length === 2 && - entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), - ); - const secondCheckpoint = secondTurnThread.checkpoints.find( - (checkpoint) => checkpoint.checkpointTurnCount === 2, - ); - assert.equal( - secondCheckpoint?.files.some((file) => file.path === "README.md"), - true, - ); - - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); - assert.deepEqual( - checkpointRows.map((row) => row.checkpointTurnCount), - [1, 2], - ); - - const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({ - cwd: harness.workspaceDir, - fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), - toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), - fallbackFromToHead: false, - }); - assert.equal(incrementalDiff.includes("README.md"), true); - - const fullDiff = yield* harness.checkpointStore.diffCheckpoints({ - cwd: harness.workspaceDir, - fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), - toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), - fallbackFromToHead: false, - }); - assert.equal(fullDiff.includes("README.md"), true); - - assert.equal( - gitShowFileAtRef( - harness.workspaceDir, - checkpointRefForThreadTurn(THREAD_ID, 1), - "README.md", - ), - "v2\n", - ); - assert.equal( - gitShowFileAtRef( - harness.workspaceDir, - checkpointRefForThreadTurn(THREAD_ID, 2), - "README.md", - ), - "v3\n", + entry.session?.providerName === "claudeAgent" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), ); + assert.equal(recoveredThread.session?.providerName, "claudeAgent"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); }), - ), - INTEGRATION_TEST_TIMEOUT_MS, + "claudeAgent", + ), ); -it.live( - "tracks approval requests and resolves pending approvals on user response", - () => - withHarness((harness) => +it.live("forwards claudeAgent approval responses to the provider session", () => + withHarness( + (harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -547,22 +1078,22 @@ it.live( events: [ { type: "turn.started", - ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), + ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "approval.requested", - ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, requestKind: "command", - detail: "Approve command execution", + detail: "Approve Claude tool call", }, { type: "turn.completed", - ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -572,58 +1103,48 @@ it.live( yield* startTurn({ harness, - commandId: "cmd-turn-start-approval", - messageId: "msg-user-approval", - text: "Run command needing approval", + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); const thread = yield* harness.waitForThread(THREAD_ID, (entry) => entry.activities.some((activity) => activity.kind === "approval.requested"), ); - assert.equal( - thread.activities.some((activity) => activity.kind === "approval.requested"), - true, - ); - - const pendingRow = yield* harness.waitForPendingApproval( - "req-approval-1", - (row) => row.status === "pending" && row.decision === null, - ); - assert.equal(pendingRow.status, "pending"); + assert.equal(thread.session?.threadId, "thread-1"); yield* harness.engine.dispatch({ type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-approval-respond"), + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), threadId: THREAD_ID, requestId: APPROVAL_REQUEST_ID, decision: "accept", createdAt: nowIso(), }); - const resolvedRow = yield* harness.waitForPendingApproval( + yield* harness.waitForPendingApproval( "req-approval-1", (row) => row.status === "resolved" && row.decision === "accept", ); - assert.equal(resolvedRow.status, "resolved"); - assert.equal(resolvedRow.decision, "accept"); const approvalResponses = yield* waitForSync( () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), (responses) => responses.length === 1, - "provider approval response", + "claude provider approval response", ); - assert.equal(approvalResponses.length, 1); - assert.equal(approvalResponses[0]?.requestId, "req-approval-1"); assert.equal(approvalResponses[0]?.decision, "accept"); }), - ), - INTEGRATION_TEST_TIMEOUT_MS, + "claudeAgent", + ), ); -it.live( - "records failed turn runtime state and checkpoint status as error", - () => - withHarness((harness) => +it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => + withHarness( + (harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -631,81 +1152,68 @@ it.live( events: [ { type: "turn.started", - ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "content.delta", - ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), + ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - payload: { - streamKind: "assistant_text", - delta: "Partial output before failure.\n", - }, }, { - type: "runtime.error", - ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - payload: { - message: "Sandbox command failed.", - }, + delta: "Long running output.\n", }, { type: "turn.completed", - ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - payload: { - state: "failed", - errorMessage: "Sandbox command failed.", - }, + status: "completed", }, ], }); yield* startTurn({ harness, - commandId: "cmd-turn-start-failure", - messageId: "msg-user-failure", - text: "Run risky command", + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); const thread = yield* harness.waitForThread( THREAD_ID, - (entry) => - entry.session?.status === "error" && - entry.session?.lastError === "Sandbox command failed." && - entry.activities.some((activity) => activity.kind === "runtime.error") && - entry.checkpoints.length === 1, + (entry) => entry.session?.threadId === "thread-1", ); - assert.equal(thread.session?.status, "error"); - assert.equal(thread.checkpoints[0]?.status, "error"); + assert.equal(thread.session?.threadId, "thread-1"); - const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), threadId: THREAD_ID, - checkpointTurnCount: 1, + createdAt: nowIso(), }); - assert.equal(Option.isSome(checkpointRow), true); - if (Option.isSome(checkpointRow)) { - assert.equal(checkpointRow.value.status, "error"); - } - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), - true, + yield* harness.waitForDomainEvent( + (event) => event.type === "thread.turn-interrupt-requested", + ); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness!.getInterruptCalls(THREAD_ID), + (calls) => calls.length === 1, + "claude provider interrupt call", ); + assert.equal(interruptCalls.length, 1); }), - ), - INTEGRATION_TEST_TIMEOUT_MS, + "claudeAgent", + ), ); -it.live( - "reverts to an earlier checkpoint and trims checkpoint projections + git refs", - () => - withHarness((harness) => +it.live("reverts claudeAgent turns and rolls back provider conversation state", () => + withHarness( + (harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -713,38 +1221,20 @@ it.live( events: [ { type: "turn.started", - ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "tool.started", - ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "tool.completed", - ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", }, { type: "message.delta", - ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - delta: "Updated README to v2.\n", + delta: "README -> v2\n", }, { type: "turn.completed", - ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -755,54 +1245,42 @@ it.live( fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); }), }); + yield* startTurn({ harness, - commandId: "cmd-turn-start-revert-1", - messageId: "msg-user-revert-1", - text: "First edit", + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); yield* harness.waitForThread( THREAD_ID, - (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", ); yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", - ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "tool.started", - ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "tool.completed", - ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", }, { type: "message.delta", - ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - delta: "Updated README to v3.\n", + delta: "README -> v3\n", }, { type: "turn.completed", - ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -813,11 +1291,12 @@ it.live( fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); }), }); + yield* startTurn({ harness, - commandId: "cmd-turn-start-revert-2", - messageId: "msg-user-revert-2", - text: "Second edit", + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", }); yield* harness.waitForThread( @@ -825,546 +1304,33 @@ it.live( (entry) => entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && - entry.activities.some((activity) => activity.turnId === "turn-2"), - 8000, + entry.session?.providerName === "claudeAgent", ); yield* harness.engine.dispatch({ type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), threadId: THREAD_ID, turnCount: 1, createdAt: nowIso(), }); - yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); const revertedThread = yield* harness.waitForThread( THREAD_ID, (entry) => entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, ); assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); - assert.deepEqual( - revertedThread.messages.map((message) => ({ role: message.role, text: message.text })), - [ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ], - ); - assert.equal( - revertedThread.activities.some((activity) => activity.turnId === "turn-2"), - false, - ); - assert.equal( - revertedThread.activities.some( - (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started", - ), - true, - ); assert.equal( - revertedThread.activities.some( - (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed", - ), + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), true, ); - assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); assert.equal( gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), false, ); assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); - - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); - assert.equal(checkpointRows.length, 1); }), - ), - INTEGRATION_TEST_TIMEOUT_MS, -); - -it.live( - "appends checkpoint.revert.failed activity when revert is requested without an active session", - () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), - threadId: THREAD_ID, - turnCount: 0, - createdAt: nowIso(), - }); - - const thread = yield* harness.waitForThread(THREAD_ID, (entry) => - entry.activities.some( - (activity) => - activity.kind === "checkpoint.revert.failed" && - typeof activity.payload === "object" && - activity.payload !== null, - ), - ); - const failureActivity = thread.activities.find( - (activity) => activity.kind === "checkpoint.revert.failed", - ); - assert.equal(failureActivity !== undefined, true); - assert.equal( - String( - (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, - ).includes("No active provider session"), - true, - ); - }), - ), - INTEGRATION_TEST_TIMEOUT_MS, -); - -it.live( - "starts a claudeAgent session on first turn when provider is requested", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Claude first turn.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-initial", - messageId: "msg-user-claude-initial", - text: "Use Claude", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.providerName === "claudeAgent" && - entry.session.status === "ready" && - entry.messages.some( - (message) => - message.role === "assistant" && message.text === "Claude first turn.\n", - ), - ); - assert.equal(thread.session?.providerName, "claudeAgent"); - }), - "claudeAgent", - ), - INTEGRATION_TEST_TIMEOUT_MS, -); - -it.live( - "recovers claudeAgent sessions after provider stopAll using persisted resume state", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Turn before restart.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-recover-1", - messageId: "msg-user-claude-recover-1", - text: "Before restart", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", - ); - - yield* harness.adapterHarness!.adapter.stopAll(); - yield* waitForSync( - () => harness.adapterHarness!.listActiveSessionIds(), - (sessionIds) => sessionIds.length === 0, - "provider stopAll", - ); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Turn after restart.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-recover-2", - messageId: "msg-user-claude-recover-2", - text: "After restart", - }); - yield* waitForSync( - () => harness.adapterHarness!.getStartCount(), - (count) => count === 2, - "claude provider recovery start", - ); - - const recoveredThread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.providerName === "claudeAgent" && - entry.messages.some( - (message) => message.role === "user" && message.text === "After restart", - ) && - !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), - ); - assert.equal(recoveredThread.session?.providerName, "claudeAgent"); - assert.equal(recoveredThread.session?.threadId, "thread-1"); - }), - "claudeAgent", - ), - INTEGRATION_TEST_TIMEOUT_MS, -); - -it.live( - "forwards claudeAgent approval responses to the provider session", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "approval.requested", - ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - requestId: APPROVAL_REQUEST_ID, - requestKind: "command", - detail: "Approve Claude tool call", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-approval", - messageId: "msg-user-claude-approval", - text: "Need approval", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - const thread = yield* harness.waitForThread(THREAD_ID, (entry) => - entry.activities.some((activity) => activity.kind === "approval.requested"), - ); - assert.equal(thread.session?.threadId, "thread-1"); - - yield* harness.engine.dispatch({ - type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), - threadId: THREAD_ID, - requestId: APPROVAL_REQUEST_ID, - decision: "accept", - createdAt: nowIso(), - }); - - yield* harness.waitForPendingApproval( - "req-approval-1", - (row) => row.status === "resolved" && row.decision === "accept", - ); - - const approvalResponses = yield* waitForSync( - () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), - (responses) => responses.length === 1, - "claude provider approval response", - ); - assert.equal(approvalResponses[0]?.decision, "accept"); - }), - "claudeAgent", - ), - INTEGRATION_TEST_TIMEOUT_MS, -); - -it.live( - "forwards thread.turn.interrupt to claudeAgent provider sessions", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Long running output.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-interrupt", - messageId: "msg-user-claude-interrupt", - text: "Start long turn", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => entry.session?.threadId === "thread-1", - ); - assert.equal(thread.session?.threadId, "thread-1"); - - yield* harness.engine.dispatch({ - type: "thread.turn.interrupt", - commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), - threadId: THREAD_ID, - createdAt: nowIso(), - }); - yield* harness.waitForDomainEvent( - (event) => event.type === "thread.turn-interrupt-requested", - ); - - const interruptCalls = yield* waitForSync( - () => harness.adapterHarness!.getInterruptCalls(THREAD_ID), - (calls) => calls.length === 1, - "claude provider interrupt call", - ); - assert.equal(interruptCalls.length, 1); - }), - "claudeAgent", - ), - INTEGRATION_TEST_TIMEOUT_MS, -); - -it.live( - "reverts claudeAgent turns and rolls back provider conversation state", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "README -> v2\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); - }), - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-revert-1", - messageId: "msg-user-claude-revert-1", - text: "First Claude edit", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", - ); - - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "README -> v3\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); - }), - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-revert-2", - messageId: "msg-user-claude-revert-2", - text: "Second Claude edit", - }); - - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-2" && - entry.checkpoints.length === 2 && - entry.session?.providerName === "claudeAgent", - ); - - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), - threadId: THREAD_ID, - turnCount: 1, - createdAt: nowIso(), - }); - - const revertedThread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, - ); - assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), - true, - ); - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), - false, - ); - assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); - }), - "claudeAgent", - ), - INTEGRATION_TEST_TIMEOUT_MS, + "claudeAgent", + ), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 8a2dec4cef..6e7b18277c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -88,38 +88,35 @@ function buildLargeText(lineCount = 5_000): string { it.layer(TestLayer)("CheckpointStoreLive", (it) => { describe("diffCheckpoints", () => { - it.effect( - "returns full oversized checkpoint diffs without truncation", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const checkpointStore = yield* CheckpointStore; - const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); - const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + it.effect("returns full oversized checkpoint diffs without truncation", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - yield* checkpointStore.captureCheckpoint({ - cwd: tmp, - checkpointRef: fromCheckpointRef, - }); - yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); - yield* checkpointStore.captureCheckpoint({ - cwd: tmp, - checkpointRef: toCheckpointRef, - }); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); - const diff = yield* checkpointStore.diffCheckpoints({ - cwd: tmp, - fromCheckpointRef, - toCheckpointRef, - }); + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + }); - expect(diff).toContain("diff --git"); - expect(diff).not.toContain("[truncated]"); - expect(diff).toContain("+line 04999"); - }), - 60_000, + expect(diff).toContain("diff --git"); + expect(diff).not.toContain("[truncated]"); + expect(diff).toContain("+line 04999"); + }), ); }); }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 55b0ca5ea6..cca74cd549 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -646,7 +646,6 @@ function makeManager(input?: { } const asThreadId = (threadId: string) => threadId as ThreadId; -const SLOW_GIT_MANAGER_TEST_TIMEOUT_MS = 60_000; const GitManagerTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), @@ -654,48 +653,45 @@ const GitManagerTestLayer = GitCoreLive.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { - it.effect( - "status includes PR metadata when branch already has an open PR", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); + it.effect("status includes PR metadata when branch already has an open PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseRefName: "main", - headRefName: "feature/status-open-pr", - }, - ]), - ], - }, - }); + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 13, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/13", + baseRefName: "main", + headRefName: "feature/status-open-pr", + }, + ]), + ], + }, + }); - const status = yield* manager.status({ cwd: repoDir }); - expect(status.isRepo).toBe(true); - expect(status.hasOriginRemote).toBe(true); - expect(status.isDefaultBranch).toBe(false); - expect(status.branch).toBe("feature/status-open-pr"); - expect(status.pr).toEqual({ - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseBranch: "main", - headBranch: "feature/status-open-pr", - state: "open", - }); - }), - SLOW_GIT_MANAGER_TEST_TIMEOUT_MS, + const status = yield* manager.status({ cwd: repoDir }); + expect(status.isRepo).toBe(true); + expect(status.hasOriginRemote).toBe(true); + expect(status.isDefaultBranch).toBe(false); + expect(status.branch).toBe("feature/status-open-pr"); + expect(status.pr).toEqual({ + number: 13, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/13", + baseBranch: "main", + headBranch: "feature/status-open-pr", + state: "open", + }); + }), ); it.effect("status returns an explicit non-repo result for non-git directories", () => @@ -796,69 +792,66 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect( - "status detects cross-repo PRs from the upstream remote URL owner", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); - yield* runGit(repoDir, ["add", "fork-pr.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:jasonLaster/codething-mvp.git", - ]); + it.effect("status detects cross-repo PRs from the upstream remote URL owner", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); + yield* runGit(repoDir, ["add", "fork-pr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:jasonLaster/codething-mvp.git", + ]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([ - { - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - updatedAt: "2026-03-10T07:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "jasonLaster/codething-mvp", - }, - headRepositoryOwner: { - login: "jasonLaster", - }, + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([ + { + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", }, - ]), - ], - }, - }); + headRepositoryOwner: { + login: "jasonLaster", + }, + }, + ]), + ], + }, + }); - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("t3code/pr-488/statemachine"); - expect(status.pr).toEqual({ - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseBranch: "main", - headBranch: "statemachine", - state: "open", - }); - expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ); - }), - SLOW_GIT_MANAGER_TEST_TIMEOUT_MS, + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("t3code/pr-488/statemachine"); + expect(status.pr).toEqual({ + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseBranch: "main", + headBranch: "statemachine", + state: "open", + }); + expect(ghCalls).toContain( + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), ); it.effect( @@ -1181,72 +1174,67 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect( - "creates feature branch, commits, and pushes with featureBranch option", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); - let generatedCount = 0; + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); + let generatedCount = 0; - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "Implement stacked git actions", - body: "", - ...(input.includeBranch - ? { branch: "feature/implement-stacked-git-actions" } - : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - featureBranch: true, - }); + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "Implement stacked git actions", + body: "", + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + featureBranch: true, + }); - expect(result.branch.status).toBe("created"); - expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Create PR", - action: { - kind: "create_pr", - }, + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Create PR", + action: { + kind: "create_pr", }, - }); - expect(result.toast.title).toMatch( - /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, - ); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("feature/implement-stacked-git-actions"); + }, + }); + expect(result.toast.title).toMatch( + /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, + ); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("feature/implement-stacked-git-actions"); - const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - expect(mergeBase).toBe(mainSha); - expect(generatedCount).toBe(1); - }), - SLOW_GIT_MANAGER_TEST_TIMEOUT_MS, + const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + expect(mergeBase).toBe(mainSha); + expect(generatedCount).toBe(1); + }), ); it.effect("featureBranch uses custom commit message and derives branch name", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4c7bc889d4..85145d9502 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -242,4 +242,8 @@ export const makeServerLayer = Layer.unwrap( ); // Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. -export const runServer = Layer.launch(makeServerLayer); +export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect< + never, + any, + ServerConfig +>; From b42a75d5aae8f459f61c922e5258dc7465053dc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:44:09 -0700 Subject: [PATCH 08/18] kewl --- apps/server/src/git/Layers/GitCore.test.ts | 184 ++++++++---------- apps/server/src/git/Layers/GitManager.test.ts | 117 +++++------ 2 files changed, 145 insertions(+), 156 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 97d69dc166..5e4416d8b9 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -20,8 +20,6 @@ const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(NodeServices.layer), ); const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); -const SLOW_GIT_CORE_TEST_TIMEOUT_MS = 60_000; -const SLOW_GIT_CORE_WAIT_TIMEOUT_MS = 30_000; function makeTmpDir( prefix = "git-test-", @@ -330,51 +328,48 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect( - "keeps default branch right after current branch", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; + it.effect("keeps default branch right after current branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - yield* commitWithDate( - tmp, - "current.txt", - "current change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "current change", - ); + yield* git(remote, ["init", "--bare"]); + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", defaultBranch]); + yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer change", - ); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); + yield* commitWithDate( + tmp, + "current.txt", + "current change\n", + "Thu, 1 Jan 2037 00:00:00 +0000", + "current change", + ); + + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); + yield* commitWithDate( + tmp, + "newer.txt", + "newer change\n", + "Fri, 1 Jan 2038 00:00:00 +0000", + "newer change", + ); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("current-branch"); - expect(result.branches[1]!.name).toBe(defaultBranch); - expect(result.branches[2]!.name).toBe("newer-branch"); - }), - SLOW_GIT_CORE_TEST_TIMEOUT_MS, + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + expect(result.branches[0]!.name).toBe("current-branch"); + expect(result.branches[1]!.name).toBe(defaultBranch); + expect(result.branches[2]!.name).toBe("newer-branch"); + }), ); it.effect("lists multiple branches after creating them", () => @@ -620,67 +615,58 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect( - "refreshes upstream behind count after checkout when remote branch advanced", - () => - Effect.gen(function* () { - const services = yield* Effect.services(); - const runPromise = Effect.runPromiseWith(services); + it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => + Effect.gen(function* () { + const services = yield* Effect.services(); + const runPromise = Effect.runPromiseWith(services); - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); + yield* initRepoWithCommit(source); + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); - const featureBranch = "feature-behind"; - yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); + const featureBranch = "feature-behind"; + yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); + yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature base"]); + yield* git(source, ["push", "-u", "origin", featureBranch]); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, [ - "checkout", - "-b", - featureBranch, - "--track", - `origin/${featureBranch}`, - ]); - yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); - yield* git(clone, ["add", "feature.txt"]); - yield* git(clone, ["commit", "-m", "remote feature update"]); - yield* git(clone, ["push", "origin", featureBranch]); + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); + yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); + yield* git(clone, ["add", "feature.txt"]); + yield* git(clone, ["commit", "-m", "remote feature update"]); + yield* git(clone, ["push", "origin", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - const core = yield* GitCore; - yield* Effect.promise(() => - vi.waitFor( - async () => { - const details = await runPromise(core.statusDetails(source)); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }, - { - timeout: SLOW_GIT_CORE_WAIT_TIMEOUT_MS, - interval: 100, - }, - ), - ); - }), - SLOW_GIT_CORE_TEST_TIMEOUT_MS, + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); + const core = yield* GitCore; + yield* Effect.promise(() => + vi.waitFor( + async () => { + const details = await runPromise(core.statusDetails(source)); + expect(details.branch).toBe(featureBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }, + { + timeout: 10_000, + interval: 100, + }, + ), + ); + }), ); it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index cca74cd549..c7ca3c52ea 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -792,66 +792,69 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("status detects cross-repo PRs from the upstream remote URL owner", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); - yield* runGit(repoDir, ["add", "fork-pr.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:jasonLaster/codething-mvp.git", - ]); + it.effect( + "status detects cross-repo PRs from the upstream remote URL owner", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); + yield* runGit(repoDir, ["add", "fork-pr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:jasonLaster/codething-mvp.git", + ]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([ - { - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - updatedAt: "2026-03-10T07:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "jasonLaster/codething-mvp", - }, - headRepositoryOwner: { - login: "jasonLaster", + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([ + { + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", + }, + headRepositoryOwner: { + login: "jasonLaster", + }, }, - }, - ]), - ], - }, - }); + ]), + ], + }, + }); - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("t3code/pr-488/statemachine"); - expect(status.pr).toEqual({ - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseBranch: "main", - headBranch: "statemachine", - state: "open", - }); - expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ); - }), + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("t3code/pr-488/statemachine"); + expect(status.pr).toEqual({ + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseBranch: "main", + headBranch: "statemachine", + state: "open", + }); + expect(ghCalls).toContain( + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 12_000, ); it.effect( From dcaf3aae5d451efeba4454e161cc6f78c5c56c75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:46:00 -0700 Subject: [PATCH 09/18] kewl --- apps/server/src/git/Layers/GitManager.test.ts | 2 +- apps/server/src/git/Layers/GitManager.ts | 2 +- .../Layers/CheckpointReactor.test.ts | 1313 ++++++++--------- .../Layers/ProjectSetupScriptRunner.test.ts | 0 .../Layers/ProjectSetupScriptRunner.ts | 0 .../Services/ProjectSetupScriptRunner.ts | 2 +- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 2 +- apps/server/src/ws.ts | 2 +- 9 files changed, 629 insertions(+), 696 deletions(-) rename apps/server/src/{projectScripts => project}/Layers/ProjectSetupScriptRunner.test.ts (100%) rename apps/server/src/{projectScripts => project}/Layers/ProjectSetupScriptRunner.ts (100%) rename apps/server/src/{projectScripts => project}/Services/ProjectSetupScriptRunner.ts (95%) diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index c7ca3c52ea..005bdb5bc6 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -30,7 +30,7 @@ import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerInput, type ProjectSetupScriptRunnerShape, -} from "../../projectScripts/Services/ProjectSetupScriptRunner.ts"; +} from "../../project/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 263d20d606..7fedb15714 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -26,7 +26,7 @@ import { import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../../projectScripts/Services/ProjectSetupScriptRunner.ts"; +import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index afd96da004..ab9f633e02 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -44,8 +44,6 @@ import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -const CHECKPOINT_REACTOR_WAIT_TIMEOUT_MS = 30_000; -const CHECKPOINT_REACTOR_TEST_TIMEOUT_MS = 60_000; type LegacyProviderRuntimeEvent = { readonly type: string; @@ -119,7 +117,7 @@ async function waitForThread( checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; }) => boolean, - timeoutMs = CHECKPOINT_REACTOR_WAIT_TIMEOUT_MS, + timeoutMs = 15_000, ) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise<{ @@ -144,7 +142,7 @@ async function waitForThread( async function waitForEvent( engine: OrchestrationEngineShape, predicate: (event: { type: string }) => boolean, - timeoutMs = CHECKPOINT_REACTOR_WAIT_TIMEOUT_MS, + timeoutMs = 15_000, ) { const deadline = Date.now() + timeoutMs; const poll = async () => { @@ -195,11 +193,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { return runGit(cwd, ["show", `${ref}:${filePath}`]); } -async function waitForGitRefExists( - cwd: string, - ref: string, - timeoutMs = CHECKPOINT_REACTOR_WAIT_TIMEOUT_MS, -) { +async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { if (gitRefExists(cwd, ref)) { @@ -352,766 +346,705 @@ describe("CheckpointReactor", () => { }; } - it( - "captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", - async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = new Date().toISOString(); + it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const createdAt = new Date().toISOString(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-capture"), + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-capture"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - harness.provider.emit({ - type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-1"), - provider: "codex", + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-1"), + provider: "codex", - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-1"), - }); - await waitForGitRefExists( + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-1"), + provider: "codex", + + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, + ); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + ).toBe(true); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + expect( + gitShowFileAtRef( harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - ); + "README.md", + ), + ).toBe("v1\n"); + expect( + gitShowFileAtRef( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + "README.md", + ), + ).toBe("v2\n"); + }); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-1"), - provider: "codex", + it("ignores auxiliary thread turn completion while primary turn is active", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const createdAt = new Date().toISOString(); - createdAt: new Date().toISOString(), + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"), threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-1"), - payload: { state: "completed" }, - }); + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-main"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.engine, - (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, - ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), - ).toBe(true); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), - ).toBe(true); - expect( - gitShowFileAtRef( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - "README.md", - ), - ).toBe("v1\n"); - expect( - gitShowFileAtRef( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), - "README.md", - ), - ).toBe("v2\n"); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-main"), + provider: "codex", - it( - "ignores auxiliary thread turn completion while primary turn is active", - async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = new Date().toISOString(); + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-main"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "running", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: asTurnId("turn-main"), - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); - harness.provider.emit({ - type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-main"), - provider: "codex", + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-aux"), + provider: "codex", - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-main"), - }); - await waitForGitRefExists( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - ); + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-aux"), + payload: { state: "completed" }, + }); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + await harness.drain(); + const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midThread = midReadModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + expect(midThread?.checkpoints).toHaveLength(0); - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-aux"), - provider: "codex", + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-main"), + provider: "codex", - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-aux"), - payload: { state: "completed" }, - }); - - await harness.drain(); - const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); - const midThread = midReadModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - expect(midThread?.checkpoints).toHaveLength(0); + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-main"), + payload: { state: "completed" }, + }); - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-main"), - provider: "codex", + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, + ); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + }); - createdAt: new Date().toISOString(), + it("captures pre-turn and completion checkpoints for claude runtime events", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerName: "claudeAgent", + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-main"), - payload: { state: "completed" }, - }); + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - const thread = await waitForThread( - harness.engine, - (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, - ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), + provider: "claudeAgent", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); - it( - "captures pre-turn and completion checkpoints for claude runtime events", - async () => { - const harness = await createHarness({ - seedFilesystemCheckpoints: false, - providerName: "claudeAgent", - }); - const createdAt = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "claudeAgent", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), + provider: "claudeAgent", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + payload: { state: "completed" }, + }); - harness.provider.emit({ - type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), - provider: "claudeAgent", - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-claude-1"), - }); - await waitForGitRefExists( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - ); + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, + ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), - provider: "claudeAgent", - createdAt: new Date().toISOString(), + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + }); + + it("appends capture failure activity when turn diff summary cannot be derived", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"), threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-claude-1"), - payload: { state: "completed" }, - }); + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.engine, - (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, - ); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), + provider: "codex", - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), - ).toBe(true); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-missing-baseline"), + payload: { state: "completed" }, + }); - it( - "appends capture failure activity when turn diff summary cannot be derived", - async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = new Date().toISOString(); + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => + entry.checkpoints.length === 1 && + entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(true); + }); - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), - provider: "codex", + it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); - createdAt: new Date().toISOString(), + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-missing-baseline"), - payload: { state: "completed" }, - }); - - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.engine, - (entry) => - entry.checkpoints.length === 1 && - entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ); + message: { + messageId: MessageId.makeUnsafe("message-user-1"), + role: "user", + text: "start turn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: new Date().toISOString(), + }), + ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ).toBe(true); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + expect( + gitShowFileAtRef( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + "README.md", + ), + ).toBe("v1\n"); + }); - it( - "captures pre-turn baseline from project workspace root when thread worktree is unset", - async () => { - const harness = await createHarness({ - hasSession: false, - seedFilesystemCheckpoints: false, - threadWorktreePath: null, - }); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - message: { - messageId: MessageId.makeUnsafe("message-user-1"), - role: "user", - text: "start turn", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + status: "running", + providerName: "codex", runtimeMode: "approval-required", - createdAt: new Date().toISOString(), - }), - ); + activeTurnId: asTurnId("turn-missing-cwd"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), + provider: "codex", - await waitForGitRefExists( + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-missing-cwd"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + expect( + gitShowFileAtRef( harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - ); - expect( - gitShowFileAtRef( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - "README.md", - ), - ).toBe("v1\n"); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + "README.md", + ), + ).toBe("v2\n"); + }); - it( - "captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", - async () => { - const harness = await createHarness({ - hasSession: false, - seedFilesystemCheckpoints: false, - threadWorktreePath: null, - }); - const createdAt = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), + it("ignores non-v2 checkpoint.captured runtime events", async () => { + const harness = await createHarness(); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "running", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: asTurnId("turn-missing-cwd"), - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), - provider: "codex", + harness.provider.emit({ + type: "checkpoint.captured", + eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), + provider: "codex", - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-missing-cwd"), - payload: { state: "completed" }, - }); - - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), - ).toBe(true); - expect( - gitShowFileAtRef( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), - "README.md", - ), - ).toBe("v2\n"); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-3"), + turnCount: 3, + status: "completed", + }); - it( - "ignores non-v2 checkpoint.captured runtime events", - async () => { - const harness = await createHarness(); - const createdAt = new Date().toISOString(); + await harness.drain(); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( + false, + ); + }); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + it("continues processing runtime events after a single checkpoint runtime failure", async () => { + const nonRepositorySessionCwd = fs.mkdtempSync( + path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), + ); + tempDirs.push(nonRepositorySessionCwd); - harness.provider.emit({ - type: "checkpoint.captured", - eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), - provider: "codex", + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerSessionCwd: nonRepositorySessionCwd, + }); + const createdAt = new Date().toISOString(); - createdAt: new Date().toISOString(), + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"), threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-3"), - turnCount: 3, - status: "completed", - }); - - await harness.drain(); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( - false, - ); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); - - it( - "continues processing runtime events after a single checkpoint runtime failure", - async () => { - const nonRepositorySessionCwd = fs.mkdtempSync( - path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), - ); - tempDirs.push(nonRepositorySessionCwd); - - const harness = await createHarness({ - seedFilesystemCheckpoints: false, - providerSessionCwd: nonRepositorySessionCwd, - }); - const createdAt = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), - provider: "codex", + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-runtime-failure"), - payload: { state: "completed" }, - }); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), + provider: "codex", - harness.provider.emit({ - type: "turn.started", - eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), - provider: "codex", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-runtime-failure"), + payload: { state: "completed" }, + }); - createdAt: new Date().toISOString(), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-after-runtime-failure"), - }); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), + provider: "codex", - await waitForGitRefExists( - harness.cwd, - checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), - ); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), - ).toBe(true); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-after-runtime-failure"), + }); - it( - "executes provider revert and emits thread.reverted for checkpoint revert requests", - async () => { - const harness = await createHarness(); - const createdAt = new Date().toISOString(); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + ).toBe(true); + }); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { + const harness = await createHarness(); + const createdAt = new Date().toISOString(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-1"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-2"), + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-revert-request"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnCount: 1, - createdAt, - }), - ); + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); - const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); - expect(thread.latestTurn?.turnId).toBe("turn-1"); - expect(thread.checkpoints).toHaveLength(1); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); - expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-request"), threadId: ThreadId.makeUnsafe("thread-1"), - numTurns: 1, - }); - expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), - ).toBe(false); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + turnCount: 1, + createdAt, + }), + ); - it( - "executes provider revert and emits thread.reverted for claude sessions", - async () => { - const harness = await createHarness({ providerName: "claudeAgent" }); - const createdAt = new Date().toISOString(); + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "claudeAgent", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + expect(thread.latestTurn?.turnId).toBe("turn-1"); + expect(thread.checkpoints).toHaveLength(1); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), + ).toBe(false); + }); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-claude-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), + it("executes provider revert and emits thread.reverted for claude sessions", async () => { + const harness = await createHarness({ providerName: "claudeAgent" }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-claude-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnCount: 1, - createdAt, - }), - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); - await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); - expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); - expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), threadId: ThreadId.makeUnsafe("thread-1"), - numTurns: 1, - }); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + turnCount: 1, + createdAt, + }), + ); - it( - "processes consecutive revert requests with deterministic rollback sequencing", - async () => { - const harness = await createHarness(); - const createdAt = new Date().toISOString(); + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + }); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { + const harness = await createHarness(); + const createdAt = new Date().toISOString(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"), + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { threadId: ThreadId.makeUnsafe("thread-1"), - turnId: asTurnId("turn-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnCount: 1, - createdAt, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnCount: 0, - createdAt, - }), - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); - const deadline = Date.now() + CHECKPOINT_REACTOR_WAIT_TIMEOUT_MS; - const waitForRollbackCalls = async (): Promise => { - if (harness.provider.rollbackConversation.mock.calls.length >= 2) { - return; - } - if (Date.now() >= deadline) { - throw new Error("Timed out waiting for rollbackConversation calls."); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - return waitForRollbackCalls(); - }; - await waitForRollbackCalls(); - - expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); - expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"), threadId: ThreadId.makeUnsafe("thread-1"), - numTurns: 1, - }); - expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ + turnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"), threadId: ThreadId.makeUnsafe("thread-1"), - numTurns: 1, - }); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + turnCount: 0, + createdAt, + }), + ); - it( - "appends an error activity when revert is requested without an active session", - async () => { - const harness = await createHarness({ hasSession: false }); - const createdAt = new Date().toISOString(); + await harness.drain(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-revert-no-session"), - threadId: ThreadId.makeUnsafe("thread-1"), - turnCount: 1, - createdAt, - }), - ); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); + expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + }); - const thread = await waitForThread(harness.engine, (entry) => - entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), - ); + it("appends an error activity when revert is requested without an active session", async () => { + const harness = await createHarness({ hasSession: false }); + const createdAt = new Date().toISOString(); - expect( - thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), - ).toBe(true); - expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); - }, - CHECKPOINT_REACTOR_TEST_TIMEOUT_MS, - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-no-session"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts similarity index 100% rename from apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.test.ts rename to apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts diff --git a/apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts similarity index 100% rename from apps/server/src/projectScripts/Layers/ProjectSetupScriptRunner.ts rename to apps/server/src/project/Layers/ProjectSetupScriptRunner.ts diff --git a/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts similarity index 95% rename from apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts rename to apps/server/src/project/Services/ProjectSetupScriptRunner.ts index efee54ae56..3828096f85 100644 --- a/apps/server/src/projectScripts/Services/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts @@ -34,4 +34,4 @@ export interface ProjectSetupScriptRunnerShape { export class ProjectSetupScriptRunner extends ServiceMap.Service< ProjectSetupScriptRunner, ProjectSetupScriptRunnerShape ->()("t3/projectScripts/ProjectSetupScriptRunner") {} +>()("t3/project/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 178930a32d..ccaab9a11b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -59,7 +59,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerShape, -} from "./projectScripts/Services/ProjectSetupScriptRunner.ts"; +} from "./project/Services/ProjectSetupScriptRunner.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85145d9502..04ffaeeeeb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -41,7 +41,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; -import { ProjectSetupScriptRunnerLive } from "./projectScripts/Layers/ProjectSetupScriptRunner"; +import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; const PtyAdapterLive = Layer.unwrap( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4b01c5f92b..92e9afbb1b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -45,7 +45,7 @@ import { TerminalManager } from "./terminal/Services/Manager"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; -import { ProjectSetupScriptRunner } from "./projectScripts/Services/ProjectSetupScriptRunner"; +import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { From 3cacb37eb09333ad2dccd2da02835297e8e28407 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:59:35 -0700 Subject: [PATCH 10/18] Persist scripted terminal state from server events - Apply terminal lifecycle events inside the terminal store - Preserve launch context and buffered events for persisted terminals --- apps/web/src/routes/__root.tsx | 29 +--------- apps/web/src/terminalStateStore.test.ts | 77 +++++++++++++++++++++++++ apps/web/src/terminalStateStore.ts | 71 +++++++++++++++++++++++ 3 files changed, 151 insertions(+), 26 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index bb229993c1..b4fcbf4095 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,5 @@ import { OrchestrationEvent, - ThreadId, type ServerLifecycleWelcomePayload, } from "@t3tools/contracts"; import { @@ -36,7 +35,6 @@ import { import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; -import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; @@ -206,8 +204,7 @@ function EventRouter() { const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); - const ensureTerminal = useTerminalStateStore((store) => store.ensureTerminal); - const setTerminalLaunchContext = useTerminalStateStore((store) => store.setTerminalLaunchContext); + const applyTerminalEvent = useTerminalStateStore((store) => store.applyTerminalEvent); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -507,26 +504,7 @@ function EventRouter() { if (thread && thread.archivedAt !== null) { return; } - if (event.type === "started" || event.type === "restarted") { - const threadId = ThreadId.makeUnsafe(event.threadId); - ensureTerminal(threadId, event.terminalId, { open: true, active: true }); - setTerminalLaunchContext(threadId, { - cwd: event.snapshot.cwd, - worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, - }); - } - useTerminalStateStore.getState().recordTerminalEvent(event); - const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); - if (hasRunningSubprocess === null) { - return; - } - useTerminalStateStore - .getState() - .setTerminalActivity( - ThreadId.makeUnsafe(event.threadId), - event.terminalId, - hasRunningSubprocess, - ); + applyTerminalEvent(event); }); return () => { disposed = true; @@ -540,14 +518,13 @@ function EventRouter() { }; }, [ applyOrchestrationEvents, - ensureTerminal, navigate, queryClient, removeTerminalState, removeOrphanedTerminalStates, + applyTerminalEvent, clearThreadUi, setProjectExpanded, - setTerminalLaunchContext, syncProjects, syncServerReadModel, syncThreads, diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index ed1656dc19..01b871c49b 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -237,6 +237,83 @@ describe("terminalStateStore actions", () => { expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]); }); + it("applies started terminal events to terminal state, launch context, and event buffer", () => { + const store = useTerminalStateStore.getState(); + store.applyTerminalEvent( + makeTerminalEvent("started", { + terminalId: "setup-bootstrap", + snapshot: { + threadId: THREAD_ID, + terminalId: "setup-bootstrap", + cwd: "/tmp/worktree", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-02T20:00:00.000Z", + }, + }), + ); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "setup-bootstrap", + ); + + expect(terminalState.terminalOpen).toBe(true); + expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); + expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); + expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({ + cwd: "/tmp/worktree", + worktreePath: "/tmp/worktree", + }); + expect(entries).toHaveLength(1); + expect(entries[0]?.event.type).toBe("started"); + }); + + it("applies activity and exited terminal events to subprocess state while buffering events", () => { + const store = useTerminalStateStore.getState(); + store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true }); + + store.applyTerminalEvent( + makeTerminalEvent("activity", { + terminalId: "terminal-2", + hasRunningSubprocess: true, + }), + ); + expect( + selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) + .runningTerminalIds, + ).toEqual(["terminal-2"]); + + store.applyTerminalEvent( + makeTerminalEvent("exited", { + terminalId: "terminal-2", + exitCode: 0, + exitSignal: null, + }), + ); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "terminal-2", + ); + + expect(terminalState.runningTerminalIds).toEqual([]); + expect(entries.map((entry) => entry.event.type)).toEqual(["activity", "exited"]); + }); + it("clears buffered terminal events when a thread terminal state is removed", () => { const store = useTerminalStateStore.getState(); store.recordTerminalEvent(makeTerminalEvent("output")); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 42ca6a9326..b6d5058a42 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -9,6 +9,7 @@ import type { TerminalEvent, ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -525,6 +526,7 @@ interface TerminalStateStoreState { hasRunningSubprocess: boolean, ) => void; recordTerminalEvent: (event: TerminalEvent) => void; + applyTerminalEvent: (event: TerminalEvent) => void; clearTerminalState: (threadId: ThreadId) => void; removeTerminalState: (threadId: ThreadId) => void; removeOrphanedTerminalStates: (activeThreadIds: Set) => void; @@ -630,6 +632,75 @@ export const useTerminalStateStore = create()( nextTerminalEventId: state.nextTerminalEventId + 1, }; }), + applyTerminalEvent: (event) => + set((state) => { + const threadId = ThreadId.makeUnsafe(event.threadId); + let nextTerminalStateByThreadId = state.terminalStateByThreadId; + let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId; + + if (event.type === "started" || event.type === "restarted") { + nextTerminalStateByThreadId = updateTerminalStateByThreadId( + nextTerminalStateByThreadId, + threadId, + (current) => { + let nextState = current; + if (!current.terminalIds.includes(event.terminalId)) { + nextState = newThreadTerminal(nextState, event.terminalId); + } + nextState = setThreadActiveTerminal(nextState, event.terminalId); + nextState = setThreadTerminalOpen(nextState, true); + return normalizeThreadTerminalState(nextState); + }, + ); + nextTerminalLaunchContextByThreadId = { + ...nextTerminalLaunchContextByThreadId, + [threadId]: { + cwd: event.snapshot.cwd, + worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, + }, + }; + } + + const key = terminalEventBufferKey(threadId, event.terminalId); + const currentEntries = + state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; + const nextEntry: TerminalEventEntry = { + id: state.nextTerminalEventId, + event, + }; + const nextEntries = + currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER + ? [...currentEntries.slice(1), nextEntry] + : [...currentEntries, nextEntry]; + const nextTerminalEventEntriesByKey = { + ...state.terminalEventEntriesByKey, + [key]: nextEntries, + }; + + const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); + if (hasRunningSubprocess !== null) { + nextTerminalStateByThreadId = updateTerminalStateByThreadId( + nextTerminalStateByThreadId, + threadId, + (current) => setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), + ); + } + + if ( + nextTerminalStateByThreadId === state.terminalStateByThreadId && + nextTerminalLaunchContextByThreadId === state.terminalLaunchContextByThreadId && + nextTerminalEventEntriesByKey[key] === state.terminalEventEntriesByKey[key] + ) { + return state; + } + + return { + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + nextTerminalEventId: state.nextTerminalEventId + 1, + }; + }), clearTerminalState: (threadId) => set((state) => { const nextTerminalStateByThreadId = updateTerminalStateByThreadId( From e1b607a3d32e3cd4c7cf65212edf1740e019fae5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 21:01:31 -0700 Subject: [PATCH 11/18] Persist terminal event buffering - Factor shared terminal event append logic - Keep launch context derivation consistent for start events --- apps/web/src/routes/__root.tsx | 1 + apps/web/src/terminalStateStore.ts | 100 ++++++++++++++--------------- 2 files changed, 51 insertions(+), 50 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b4fcbf4095..d6f56e531b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,7 @@ import { OrchestrationEvent, type ServerLifecycleWelcomePayload, + type ThreadId, } from "@t3tools/contracts"; import { Outlet, diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index b6d5058a42..121abe5c74 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -5,7 +5,7 @@ * API constrained to store actions/selectors. */ -import type { TerminalEvent, ThreadId } from "@t3tools/contracts"; +import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; @@ -251,6 +251,40 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ })); } +function appendTerminalEventEntry( + terminalEventEntriesByKey: Record>, + nextTerminalEventId: number, + event: TerminalEvent, +) { + const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId); + const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; + const nextEntry: TerminalEventEntry = { + id: nextTerminalEventId, + event, + }; + const nextEntries = + currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER + ? [...currentEntries.slice(1), nextEntry] + : [...currentEntries, nextEntry]; + + return { + terminalEventEntriesByKey: { + ...terminalEventEntriesByKey, + [key]: nextEntries, + }, + nextTerminalEventId: nextTerminalEventId + 1, + }; +} + +function launchContextFromStartEvent( + event: Extract, +): ThreadTerminalLaunchContext { + return { + cwd: event.snapshot.cwd, + worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, + }; +} + function upsertTerminalIntoGroups( state: ThreadTerminalState, terminalId: string, @@ -612,26 +646,13 @@ export const useTerminalStateStore = create()( setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), recordTerminalEvent: (event) => - set((state) => { - const key = terminalEventBufferKey(event.threadId as ThreadId, event.terminalId); - const currentEntries = - state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; - const nextEntry: TerminalEventEntry = { - id: state.nextTerminalEventId, + set((state) => + appendTerminalEventEntry( + state.terminalEventEntriesByKey, + state.nextTerminalEventId, event, - }; - const nextEntries = - currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER - ? [...currentEntries.slice(1), nextEntry] - : [...currentEntries, nextEntry]; - return { - terminalEventEntriesByKey: { - ...state.terminalEventEntriesByKey, - [key]: nextEntries, - }, - nextTerminalEventId: state.nextTerminalEventId + 1, - }; - }), + ), + ), applyTerminalEvent: (event) => set((state) => { const threadId = ThreadId.makeUnsafe(event.threadId); @@ -654,51 +675,30 @@ export const useTerminalStateStore = create()( ); nextTerminalLaunchContextByThreadId = { ...nextTerminalLaunchContextByThreadId, - [threadId]: { - cwd: event.snapshot.cwd, - worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, - }, + [threadId]: launchContextFromStartEvent(event), }; } - const key = terminalEventBufferKey(threadId, event.terminalId); - const currentEntries = - state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; - const nextEntry: TerminalEventEntry = { - id: state.nextTerminalEventId, - event, - }; - const nextEntries = - currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER - ? [...currentEntries.slice(1), nextEntry] - : [...currentEntries, nextEntry]; - const nextTerminalEventEntriesByKey = { - ...state.terminalEventEntriesByKey, - [key]: nextEntries, - }; - const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess !== null) { nextTerminalStateByThreadId = updateTerminalStateByThreadId( nextTerminalStateByThreadId, threadId, - (current) => setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), + (current) => + setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), ); } - if ( - nextTerminalStateByThreadId === state.terminalStateByThreadId && - nextTerminalLaunchContextByThreadId === state.terminalLaunchContextByThreadId && - nextTerminalEventEntriesByKey[key] === state.terminalEventEntriesByKey[key] - ) { - return state; - } + const nextEventState = appendTerminalEventEntry( + state.terminalEventEntriesByKey, + state.nextTerminalEventId, + event, + ); return { terminalStateByThreadId: nextTerminalStateByThreadId, terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, - terminalEventEntriesByKey: nextTerminalEventEntriesByKey, - nextTerminalEventId: state.nextTerminalEventId + 1, + ...nextEventState, }; }), clearTerminalState: (threadId) => From d726c1fa16fc7767bbe66433a6372ec55f455ac8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 21:05:27 -0700 Subject: [PATCH 12/18] Centralize project script helpers in shared package - Import cwd, env, and setup helpers from `@t3tools/shared/projectScripts` - Remove redundant web-local wrappers and update tests --- apps/web/src/components/ChatView.tsx | 3 +-- apps/web/src/projectScripts.test.ts | 8 ++++--- apps/web/src/projectScripts.ts | 32 ---------------------------- 3 files changed, 6 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e2a7509d6f..8563f9d253 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,6 +21,7 @@ import { TerminalOpenInput, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -111,8 +112,6 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, - projectScriptCwd, - projectScriptRuntimeEnv, projectScriptIdFromCommand, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 08678f8730..1e05ff5ef6 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, - projectScriptCwd, - projectScriptRuntimeEnv, projectScriptIdFromCommand, - setupProjectScript, } from "./projectScripts"; describe("projectScripts helpers", () => { diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index a1826971b0..03e3d29116 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -4,11 +4,6 @@ import { type KeybindingCommand, type ProjectScript, } from "@t3tools/contracts"; -import { - projectScriptCwd as sharedProjectScriptCwd, - projectScriptRuntimeEnv as sharedProjectScriptRuntimeEnv, - setupProjectScript as sharedSetupProjectScript, -} from "@t3tools/shared/projectScripts"; import { Schema } from "effect"; function normalizeScriptId(value: string): string { @@ -60,34 +55,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable) return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); } -interface ProjectScriptRuntimeEnvInput { - project: { - cwd: string; - }; - worktreePath?: string | null; - extraEnv?: Record; -} - -export function projectScriptCwd(input: { - project: { - cwd: string; - }; - worktreePath?: string | null; -}): string { - return sharedProjectScriptCwd(input); -} - -export function projectScriptRuntimeEnv( - input: ProjectScriptRuntimeEnvInput, -): Record { - return sharedProjectScriptRuntimeEnv(input); -} - export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; } - -export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null { - return sharedSetupProjectScript(scripts); -} From a5a95217c08491e9c1079d4de2b56a4569df4b53 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 21:24:05 -0700 Subject: [PATCH 13/18] Separate setup script launch and activity recording failures - Record setup-script.started and setup-script.failed separately - Avoid treating activity dispatch errors as launch failures --- apps/server/src/server.test.ts | 119 +++++++++++++++++++++++++++ apps/server/src/ws.ts | 146 ++++++++++++++++++++++----------- 2 files changed, 215 insertions(+), 50 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ccaab9a11b..e46a77ed91 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -42,6 +42,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "./orchestration/Services/OrchestrationEngine.ts"; +import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, @@ -1477,6 +1478,124 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn((_: Parameters[0]) => + Effect.succeed({ + worktree: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const runForThread = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + }), + ); + let setupActivityAppendAttempt = 0; + + yield* buildAppUnderTest({ + layers: { + gitCore: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => { + if ( + command.type === "thread.activity.append" && + command.activity.kind.startsWith("setup-script.") + ) { + setupActivityAppendAttempt += 1; + if (setupActivityAppendAttempt === 2) { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "failed to append setup-script.started activity", + }), + ); + } + } + + return Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }); + }, + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = new Date().toISOString(); + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-activity-failure"), + threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-activity-failure"), + message: { + messageId: MessageId.makeUnsafe("msg-bootstrap-setup-activity-failure"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 4); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + ); + const setupActivities = dispatchedCommands.filter( + (command): command is Extract => + command.type === "thread.activity.append", + ); + assert.deepEqual( + setupActivities.map((command) => command.activity.kind), + ["setup-script.requested"], + ); + assertTrue( + setupActivities.every((command) => command.activity.kind !== "setup-script.failed"), + ); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 92e9afbb1b..9f49b5cf1f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -134,6 +134,85 @@ const WsRpcLayer = WsRpcGroup.toLayer( .pipe(Effect.ignoreCause({ log: true })) : Effect.void; + const recordSetupScriptLaunchFailure = (input: { + readonly error: unknown; + readonly requestedAt: string; + readonly worktreePath: string; + }) => { + const detail = + input.error instanceof Error ? input.error.message : "Unknown setup failure."; + return appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: input.requestedAt, + payload: { + detail, + worktreePath: input.worktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => + Effect.logWarning("bootstrap turn start failed to launch setup script", { + threadId: command.threadId, + worktreePath: input.worktreePath, + detail, + }), + ), + ); + }; + + const recordSetupScriptStarted = (input: { + readonly requestedAt: string; + readonly worktreePath: string; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + }) => { + const payload = { + scriptId: input.scriptId, + scriptName: input.scriptName, + terminalId: input.terminalId, + worktreePath: input.worktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start launched setup script but failed to record setup activity", + { + threadId: command.threadId, + worktreePath: input.worktreePath, + scriptId: input.scriptId, + terminalId: input.terminalId, + detail: + error instanceof Error + ? error.message + : "Unknown setup activity dispatch failure.", + }, + ), + ), + ); + }; + const runSetupProgram = () => bootstrap?.runSetupScript && targetWorktreePath ? (() => { @@ -147,58 +226,25 @@ const WsRpcLayer = WsRpcGroup.toLayer( worktreePath, }) .pipe( - Effect.tap((setupResult) => { - if (setupResult.status !== "started") { - return Effect.void; - } - const payload = { - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - worktreePath, - }; - return Effect.all([ - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", - createdAt: requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: new Date().toISOString(), - payload, - tone: "info", + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, }), - ]).pipe(Effect.asVoid); - }), - Effect.catch((error) => { - const detail = - error instanceof Error ? error.message : "Unknown setup failure."; - return appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.failed", - summary: "Setup script failed to start", - createdAt: requestedAt, - payload: { - detail, + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + return recordSetupScriptStarted({ + requestedAt, worktreePath, - }, - tone: "error", - }).pipe( - Effect.ignoreCause({ log: false }), - Effect.flatMap(() => - Effect.logWarning("bootstrap turn start failed to launch setup script", { - threadId: command.threadId, - worktreePath, - detail, - }), - ), - ); + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }); + }, }), ); })() From 49bf283cc98ff56921be898f5accf7f8a901e048 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Apr 2026 10:34:33 -0700 Subject: [PATCH 14/18] Persist worktree metadata for terminal sessions - Thread worktree path through contracts, server, and UI - Preserve setup terminal context across snapshots and restarts --- .../Layers/ProjectSetupScriptRunner.test.ts | 2 + .../Layers/ProjectSetupScriptRunner.ts | 1 + apps/server/src/server.test.ts | 1 + .../src/terminal/Layers/Manager.test.ts | 37 +++++++++++++++++++ apps/server/src/terminal/Layers/Manager.ts | 11 ++++++ apps/server/src/terminal/Services/Manager.ts | 1 + apps/web/src/components/ChatView.tsx | 8 ++-- .../src/components/ThreadTerminalDrawer.tsx | 7 ++++ apps/web/src/terminalActivity.test.ts | 1 + apps/web/src/terminalStateStore.test.ts | 2 + apps/web/src/terminalStateStore.ts | 2 +- packages/contracts/src/terminal.test.ts | 26 +++++++++++++ packages/contracts/src/terminal.ts | 3 ++ 13 files changed, 97 insertions(+), 5 deletions(-) diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 7d8026eec3..6366a768b7 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -83,6 +83,7 @@ describe("ProjectSetupScriptRunner", () => { threadId: "thread-1", terminalId: "setup-setup", cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", status: "running" as const, pid: 123, history: "", @@ -150,6 +151,7 @@ describe("ProjectSetupScriptRunner", () => { threadId: "thread-1", terminalId: "setup-setup", cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a", diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index c3ac77fe52..3bac8cf0ab 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -46,6 +46,7 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { threadId: input.threadId, terminalId, cwd, + worktreePath: input.worktreePath, env, }); yield* terminalManager.write({ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e46a77ed91..3d8e36e0a7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1764,6 +1764,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { threadId: "thread-1", terminalId: "default", cwd: "/tmp/project", + worktreePath: null, status: "running" as const, pid: 1234, history: "", diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index ccdd477178..7578272f69 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -428,6 +428,43 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => + Effect.gen(function* () { + const { manager, getEvents, baseDir } = yield* createManager(); + const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); + const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); + yield* makeDirectory(firstWorktreePath); + yield* makeDirectory(secondWorktreePath); + const startedSnapshot = yield* manager.open( + openInput({ + cwd: firstWorktreePath, + worktreePath: firstWorktreePath, + }), + ); + const restartedSnapshot = yield* manager.restart( + restartInput({ + cwd: secondWorktreePath, + worktreePath: secondWorktreePath, + }), + ); + + assert.equal(startedSnapshot.worktreePath, firstWorktreePath); + assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); + + const events = yield* getEvents; + const startedEvent = events.find( + (event): event is Extract => event.type === "started", + ); + const restartedEvent = events.find( + (event): event is Extract => + event.type === "restarted", + ); + + assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); + assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); + }), + ); + it.effect("emits exited event and reopens with clean transcript after exit", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index a076e7600b..19219a8291 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -80,6 +80,7 @@ interface TerminalStartInput { threadId: string; terminalId: string; cwd: string; + worktreePath?: string | null; cols: number; rows: number; env?: Record; @@ -89,6 +90,7 @@ interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; + worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; history: string; @@ -143,6 +145,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { threadId: session.threadId, terminalId: session.terminalId, cwd: session.cwd, + worktreePath: session.worktreePath, status: session.status, pid: session.pid, history: session.history, @@ -1309,6 +1312,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith yield* modifyManagerState((state) => { session.status = "starting"; session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; session.rows = input.rows; session.exitCode = null; @@ -1577,6 +1581,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + worktreePath: input.worktreePath ?? null, status: "starting", pid: null, history, @@ -1610,6 +1615,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, rows, ...(input.env ? { env: input.env } : {}), @@ -1629,6 +1635,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (liveSession.cwd !== input.cwd || runtimeEnvChanged) { yield* stopProcess(liveSession); liveSession.cwd = input.cwd; + liveSession.worktreePath = input.worktreePath ?? null; liveSession.runtimeEnv = nextRuntimeEnv; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; @@ -1642,6 +1649,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith ); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.worktreePath = input.worktreePath ?? null; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; @@ -1751,6 +1759,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + worktreePath: input.worktreePath ?? null, status: "starting", pid: null, history: "", @@ -1780,6 +1789,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session = existingSession.value; yield* stopProcess(session); session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; session.runtimeEnv = normalizedRuntimeEnv(input.env); } @@ -1798,6 +1808,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, rows, ...(input.env ? { env: input.env } : {}), diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index bdfbc85cc5..ebe83e362b 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -37,6 +37,7 @@ export interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; + worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; history: string; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8563f9d253..1f56b90e46 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -446,11 +446,8 @@ function PersistentThreadTerminalDrawer({ if (launchContext?.worktreePath) { return launchContext.worktreePath; } - if (launchContext && terminalState.activeTerminalId.startsWith("setup-")) { - return launchContext.cwd; - } return worktreePath; - }, [launchContext, terminalState.activeTerminalId, worktreePath]); + }, [launchContext, worktreePath]); const cwd = useMemo( () => launchContext?.cwd ?? @@ -553,6 +550,7 @@ function PersistentThreadTerminalDrawer({ ; onSessionExited: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; @@ -226,6 +227,7 @@ function TerminalViewport({ terminalId, terminalLabel, cwd, + worktreePath, runtimeEnv, onSessionExited, onAddTerminalContext, @@ -597,6 +599,7 @@ function TerminalViewport({ threadId, terminalId, cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), cols: activeTerminal.cols, rows: activeTerminal.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), @@ -720,6 +723,7 @@ function TerminalViewport({ interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; + worktreePath?: string | null; runtimeEnv?: Record; visible?: boolean; height: number; @@ -771,6 +775,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA export default function ThreadTerminalDrawer({ threadId, cwd, + worktreePath, runtimeEnv, visible = true, height, @@ -1097,6 +1102,7 @@ export default function ThreadTerminalDrawer({ terminalId={terminalId} terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} cwd={cwd} + {...(worktreePath !== undefined ? { worktreePath } : {})} {...(runtimeEnv ? { runtimeEnv } : {})} onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} @@ -1117,6 +1123,7 @@ export default function ThreadTerminalDrawer({ terminalId={resolvedActiveTerminalId} terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} cwd={cwd} + {...(worktreePath !== undefined ? { worktreePath } : {})} {...(runtimeEnv ? { runtimeEnv } : {})} onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} diff --git a/apps/web/src/terminalActivity.test.ts b/apps/web/src/terminalActivity.test.ts index 8fda326266..6f38dcbfcb 100644 --- a/apps/web/src/terminalActivity.test.ts +++ b/apps/web/src/terminalActivity.test.ts @@ -7,6 +7,7 @@ const snapshot: TerminalSessionSnapshot = { threadId: "thread-1", terminalId: "default", cwd: "/tmp", + worktreePath: null, status: "running", pid: 1234, history: "", diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 01b871c49b..4ded76fba1 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -39,6 +39,7 @@ function makeTerminalEvent( threadId: THREAD_ID, terminalId: "default", cwd: "/tmp/workspace", + worktreePath: null, status: "running", pid: 123, history: "", @@ -246,6 +247,7 @@ describe("terminalStateStore actions", () => { threadId: THREAD_ID, terminalId: "setup-bootstrap", cwd: "/tmp/worktree", + worktreePath: "/tmp/worktree", status: "running", pid: 123, history: "", diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 121abe5c74..7189e715a4 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -281,7 +281,7 @@ function launchContextFromStartEvent( ): ThreadTerminalLaunchContext { return { cwd: event.snapshot.cwd, - worktreePath: event.terminalId.startsWith("setup-") ? event.snapshot.cwd : null, + worktreePath: event.snapshot.worktreePath, }; } diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 614df05141..1bef8db3a0 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -63,6 +63,7 @@ describe("TerminalOpenInput", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", cwd: "/tmp/project", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", cols: 100, rows: 24, env: { @@ -74,6 +75,7 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); + expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); }); it("rejects invalid env keys", () => { @@ -157,6 +159,7 @@ describe("TerminalSessionSnapshot", () => { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: "/tmp/project", + worktreePath: null, status: "running", pid: 1234, history: "hello\n", @@ -205,4 +208,27 @@ describe("TerminalEvent", () => { }), ).toBe(true); }); + + it("accepts started events with snapshot worktree metadata", () => { + expect( + decodes(TerminalEvent, { + type: "started", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + createdAt: new Date().toISOString(), + snapshot: { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project/.t3/worktrees/feature-a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + status: "running", + pid: 1234, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + }, + }), + ).toBe(true); + }); }); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 4344706bc2..8b20254def 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -37,6 +37,7 @@ export type TerminalSessionInput = Schema.Codec.Encoded export const TerminalRestartInput = Schema.Struct({ ...TerminalSessionInput.fields, cwd: TrimmedNonEmptyStringSchema, + worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), @@ -82,6 +84,7 @@ export const TerminalSessionSnapshot = Schema.Struct({ threadId: Schema.String.check(Schema.isNonEmpty()), terminalId: Schema.String.check(Schema.isNonEmpty()), cwd: Schema.String.check(Schema.isNonEmpty()), + worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), status: TerminalSessionStatus, pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), history: Schema.String, From 1b1f6889a36383c07edbd077e39101f0dfc1b8fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Apr 2026 13:40:31 -0700 Subject: [PATCH 15/18] Preserve worktree metadata on terminal reopen - carry worktreePath into reopened terminal snapshots - show sending state while bootstrap dispatch is pending - add regression coverage for reopened exited sessions --- .../src/terminal/Layers/Manager.test.ts | 42 +++++++++++ apps/server/src/terminal/Layers/Manager.ts | 1 + apps/server/src/ws.ts | 1 - apps/web/src/components/ChatView.browser.tsx | 69 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 2 +- 5 files changed, 113 insertions(+), 2 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 7578272f69..8207861e20 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -465,6 +465,48 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("preserves worktree metadata when reopening an exited session", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); + const worktreePath = path.join(baseDir, "worktrees", "feature-a"); + yield* makeDirectory(worktreePath); + + yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + }), + ); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + ); + + const reopenedSnapshot = yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + }), + ); + + assert.equal(reopenedSnapshot.worktreePath, worktreePath); + + const events = yield* getEvents; + const reopenedEvent = events + .toReversed() + .find( + (event): event is Extract => event.type === "started", + ); + + assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); + }), + ); + it.effect("emits exited event and reopens with clean transcript after exit", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 19219a8291..5dc216e37e 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1669,6 +1669,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + worktreePath: liveSession.worktreePath, cols: targetCols, rows: targetRows, ...(input.env ? { env: input.env } : {}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9f49b5cf1f..33a0518611 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -275,7 +275,6 @@ const WsRpcLayer = WsRpcGroup.toLayer( newBranch: bootstrap.prepareWorktree.branch, path: null, }); - targetProjectCwd = bootstrap.prepareWorktree.projectCwd; targetWorktreePath = worktree.worktree.path; yield* orchestrationEngine.dispatch({ type: "thread.meta.update", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1d69d44a39..0bce46fa22 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1936,6 +1936,75 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows the send state once bootstrap dispatch is in flight", async () => { + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + }); + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + let resolveDispatch!: (value: { sequence: number }) => void; + const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { + resolveDispatch = resolve; + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return dispatchPromise; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + expect( + wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), + ).toBe(true); + expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); + expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f56b90e46..66192bb32d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3072,6 +3072,7 @@ export default function ChatView({ threadId }: ChatViewProps) { : {}), } : undefined; + beginLocalDispatch({ preparingWorktree: false }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -3089,7 +3090,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); - beginLocalDispatch({ preparingWorktree: false }); turnStartSucceeded = true; })().catch(async (err: unknown) => { if ( From 2247f18e44a8d470f3b840c4c4b103d416554287 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Apr 2026 13:56:22 -0700 Subject: [PATCH 16/18] Include worktreePath in browser terminal RPCs - pass `worktreePath` through `resolveWsRpc` - default missing values to `null` --- apps/web/src/components/ChatView.browser.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0bce46fa22..ef3a25da0b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -683,6 +683,12 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", + worktreePath: + typeof body.worktreePath === "string" + ? body.worktreePath + : body.worktreePath === null + ? null + : null, status: "running", pid: 123, history: "", From 797f437eeefd337e015c08340986e82b5ff8eb00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Apr 2026 15:39:30 -0700 Subject: [PATCH 17/18] Honor cleared terminal launch context worktree path - Avoid leaking the thread worktree into terminal env when launch context clears it - Add regression coverage for terminal open env --- apps/web/src/components/ChatView.browser.tsx | 68 ++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ef3a25da0b..6b059d7709 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1379,6 +1379,74 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-launch-context-target" as MessageId, + targetText: "launch context worktree override", + }); + const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); + if (targetThread) { + Object.assign(targetThread, { + branch: "feature/branch", + worktreePath: "/repo/worktrees/feature-branch", + }); + } + + useTerminalStateStore.setState({ + terminalStateByThreadId: { + [THREAD_ID]: { + terminalOpen: true, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }, + terminalLaunchContextByThreadId: { + [THREAD_ID]: { + cwd: "/repo/project", + worktreePath: null, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalOpen, + ) as + | { + _tag: string; + cwd?: string; + worktreePath?: string | null; + env?: Record; + } + | undefined; + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + cwd: "/repo/project", + worktreePath: null, + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + }, + }); + expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 66192bb32d..9d51fa5061 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -443,7 +443,7 @@ function PersistentThreadTerminalDrawer({ const [localFocusRequestId, setLocalFocusRequestId] = useState(0); const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = useMemo(() => { - if (launchContext?.worktreePath) { + if (launchContext !== null) { return launchContext.worktreePath; } return worktreePath; From 58e0ed7f4eade8a5a61ebe4d9d9affb60222ea93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Apr 2026 15:47:14 -0700 Subject: [PATCH 18/18] Reset terminal store between ChatView parity tests - Clear persisted terminal state before each test - Avoid cross-test leakage from prior browser sessions --- apps/web/src/components/ChatView.browser.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6b059d7709..84302386dc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1156,6 +1156,13 @@ describe("ChatView timeline estimator parity (full app)", () => { threads: [], bootstrapComplete: false, }); + useTerminalStateStore.persist.clearStorage(); + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, + terminalEventEntriesByKey: {}, + nextTerminalEventId: 1, + }); }); afterEach(() => {