diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 0c4194b88..f50b732a9 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,6 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | +| First-run welcome wizard, agent setup and transcript import / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full nine-source list in [#459](https://github.com/pylon-code/pylon/pull/459), from #5362 through #10547 | Adopted all nine: the `/welcome` overlay with multi-computer selection, Claude Code and Codex readiness terminals with native installers, repository-grouped project import, server transcript scan/import with provider resume cursors, settings-hydration hardening, and the light-mode and theme follow-ups. Pylon adaptations: ProviderSessionDirectory keeps its commit guard and exact removal beside insert-ignore and imported transcript records; import invariants use Pylon's open-request scan and Pylon-only thread fields; tests use Pylon's rollback revision and provider-instance compare-and-sets; PylonMark and Pylon/Pylon Connect copy; scans skip Pylon runtime-home worktrees; imported projects keep resolving a default model until #9754 lands. Saved client settings decode per value and keep undecodable stored values (T3 Code's `confirmQuit` shape on a shared origin) instead of failing closed; desktop keeps the default quit hold when its settings file is unreadable. Duplicated sign-in: setup uses Pylon's terminal-free `ProviderSignInDialog` for Claude Code and keeps terminals for installs and `codex login`. Setup reopens from the command palette, and one transcript scanner is shared across connections. Prime and Antigravity stay in Settings → Providers with the other opt-in providers. `3faeee49ac67dfd9534369f1e1c627c0356b75ac` (#10832) is excluded and sequenced later. No migration. Cursor unchanged. | [Welcome wizard #459](https://github.com/pylon-code/pylon/pull/459); 1,703 focused tests, seven package typechecks, scoped lint/format and knip. | ## Deferred register diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44c..a07019596 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reads malformed settings documents as absent and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,8 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 6673c4950..39c177163 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -43,6 +43,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, proactivePanelsEnabled: true, showSkillsInSlashMenu: false, @@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -222,7 +276,7 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => + it.effect("reads a malformed settings file as no saved settings without rewriting it", () => withClientSettings( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -232,7 +286,95 @@ describe("DesktopClientSettings", () => { yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); assert.isTrue(Option.isNone(yield* settings.get)); + assert.equal(yield* fileSystem.readFileString(environment.clientSettingsPath), "{not-json"); }), ), ); + + for (const document of [ + { label: "direct", contents: '{"fontSizeCode":"large","timestampFormat":"12-hour"}' }, + { + label: "legacy", + contents: '{"settings":{"fontSizeCode":"large","timestampFormat":"12-hour"}}', + }, + ]) { + it.effect(`keeps readable ${document.label} settings beside an undecodable value`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); + + assert.deepEqual( + yield* settings.get, + Option.some({ + ...(yield* decodeClientSettingsJson("{}")), + timestampFormat: "12-hour" as const, + }), + ); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } + + it.effect("leaves an undecodable value in the file until that setting changes", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const readDocument = Effect.flatMap( + fileSystem.readFileString(environment.clientSettingsPath), + decodeRecordJson, + ); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + '{"confirmQuit":"hold","timestampFormat":"12-hour"}', + ); + + const saved = Option.getOrThrow(yield* settings.get); + assert.isTrue(saved.confirmQuit); + yield* settings.set({ ...saved, onboardingCompletedAt: "2026-09-10T12:00:00.000Z" }); + assert.deepInclude(yield* readDocument, { + confirmQuit: "hold", + timestampFormat: "12-hour", + onboardingCompletedAt: "2026-09-10T12:00:00.000Z", + }); + + yield* settings.set({ ...saved, confirmQuit: false }); + assert.deepInclude(yield* readDocument, { confirmQuit: false }); + yield* settings.set({ ...saved, confirmQuit: false, wordWrap: false }); + assert.deepInclude(yield* readDocument, { confirmQuit: false, wordWrap: false }); + }), + ), + ); + + it.effect("keeps the default quit hold when settings cannot be read", () => + Effect.gen(function* () { + const failing = DesktopClientSettings.DesktopClientSettings.of({ + get: Effect.fail( + new DesktopClientSettings.DesktopClientSettingsReadError({ + operation: "read-file", + path: "/unreadable/client-settings.json", + cause: new Error("permission denied"), + }), + ), + set: () => Effect.void, + }); + const disabled = DesktopClientSettings.DesktopClientSettings.of({ + get: Effect.succeed(Option.some({ ...clientSettings, confirmQuit: false })), + set: () => Effect.void, + }); + + assert.isTrue(yield* DesktopClientSettings.readConfirmQuit(failing)); + assert.isFalse(yield* DesktopClientSettings.readConfirmQuit(disabled)); + }), + ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27..f8085edbd 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -1,4 +1,11 @@ -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { + DEFAULT_CLIENT_SETTINGS, + decodeStoredClientSettings, + encodeStoredClientSettings, + retainUnreadClientSettings, + type ClientSettings, + type StoredClientSettings, +} from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -12,24 +19,23 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - -const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), - ); -const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +const encodeClientSettingsDocument = Schema.encodeEffect(fromLenientJson(Schema.Unknown)); + +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literal("read-file"), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", @@ -55,17 +61,23 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; } >()("@t3tools/desktop/settings/DesktopClientSettings") {} +/** + * Storage failures fail the read so the renderer can retry without replacing + * preferences it never saw. A document that parses but holds values this build + * cannot decode keeps every readable setting and defaults the rest; a document + * that is not settings JSON at all reads as no saved settings. + */ const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,20 +86,47 @@ const readClientSettings = ( ? Effect.succeed(Option.none()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( Option.match({ - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeed(Option.none()), onSome: (raw) => - decodeClientSettingsJson(raw).pipe( - Effect.map((settings) => Option.some(settings)), + decodeClientSettingsDocument(raw).pipe( + Effect.flatMap((document) => { + // Legacy files wrap the settings in a `settings` key. + const stored = decodeStoredClientSettings( + Object.hasOwn(document, "settings") ? document.settings : document, + ); + if (stored === null) { + return Effect.logWarning("Could not decode desktop client settings.").pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ); + } + const unreadSettings = Object.keys(stored.unreadValues); + return ( + unreadSettings.length === 0 + ? Effect.void + : Effect.logWarning( + "Some desktop client settings could not be decoded; using defaults for them.", + ).pipe(Effect.annotateLogs({ settingsPath, unreadSettings })) + ).pipe(Effect.as(Option.some(stored))); + }), Effect.catchTags({ SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), }), ), @@ -95,16 +134,32 @@ const readClientSettings = ( ), ); +/** Reads the hold-to-quit preference, keeping the default hold when settings cannot be read. */ +export const readConfirmQuit = ( + clientSettings: DesktopClientSettings["Service"], +): Effect.Effect => + clientSettings.get.pipe( + Effect.map( + Option.match({ + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (settings) => settings.confirmQuit, + }), + ), + Effect.orElseSucceed(() => DEFAULT_CLIENT_SETTINGS.confirmQuit), + ); + const writeClientSettings = Effect.fnUntraced(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly settingsPath: string; - readonly settings: ClientSettings; + readonly stored: StoredClientSettings; readonly suffix: string; }): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeClientSettingsJson(input.settings).pipe( + const encoded = yield* encodeClientSettingsDocument( + encodeStoredClientSettings(input.stored), + ).pipe( Effect.mapError( (cause) => new DesktopClientSettingsWriteError({ @@ -151,33 +206,38 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + // The last read document, so writes keep values this build could not decode. + const lastRead = yield* Ref.make(null); return DesktopClientSettings.of({ get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.tap((stored) => Ref.set(lastRead, Option.getOrNull(stored))), + Effect.map(Option.map((stored) => stored.settings)), Effect.withSpan("desktop.clientSettings.get"), ), set: (settings) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError( - (cause) => - new DesktopClientSettingsWriteError({ - operation: "create-temporary-file-name", - path: environment.clientSettingsPath, - cause, - }), - ), - Effect.flatMap((suffix) => - writeClientSettings({ - fileSystem, - path, - settingsPath: environment.clientSettingsPath, - settings, - suffix, - }), - ), - Effect.withSpan("desktop.clientSettings.set"), - ), + Effect.gen(function* () { + const suffix = yield* crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.clientSettingsPath, + cause, + }), + ), + ); + const stored = retainUnreadClientSettings(settings, yield* Ref.get(lastRead)); + yield* writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + stored, + suffix, + }); + yield* Ref.set(lastRead, stored); + }).pipe(Effect.withSpan("desktop.clientSettings.set")), }); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 980ba15ef..d0f283adf 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,8 +8,6 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; - import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; @@ -571,16 +569,7 @@ export const make = Effect.gen(function* () { // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. const quitHoldHandler = makeQuitHoldHandler({ platform: environment.platform, - isEnabled: () => - runPromise( - Effect.map( - clientSettings.get, - Option.match({ - onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, - onSome: (settings) => settings.confirmQuit, - }), - ), - ), + isEnabled: () => runPromise(DesktopClientSettings.readConfirmQuit(clientSettings)), notify: (state) => { if (!window.isDestroyed()) { window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); diff --git a/apps/server/package.json b/apps/server/package.json index 2d2c58cc4..60b85dac6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,6 +35,8 @@ "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", + "stream-chain": "^4.2.5", + "stream-json": "3.6.0", "yaml": "catalog:", "yauzl": "^3.4.0" }, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index fd80e2b4f..4fa695755 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -100,6 +100,15 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 0fbf3d2d5..a6b257ac6 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 21356e955..7549b168b 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -91,6 +91,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -205,6 +206,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -294,6 +296,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index c85af28f5..4dab1738e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1451,6 +1451,36 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); + it("does not create checkpoints while importing historical user messages", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + if (runtime === null) throw new Error("Checkpoint test runtime was not initialized."); + + await runtime.runPromise( + harness.engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("cmd-import-history-without-checkpoint"), + threadId: ThreadId.make("thread-1"), + messages: [ + { + messageId: MessageId.make("imported-user-message"), + role: "user", + text: "A message from an existing agent session", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }), + ); + await harness.drain(); + + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), + ).toBe(false); + }); + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { const harness = await createHarness({ hasSession: false, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 938f8ca23..e778d7045 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -753,6 +753,7 @@ export const make = Effect.gen(function* () { ) { if (event.type === "thread.message-sent") { if ( + event.metadata.historyImport === true || event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 883c77c1e..6eee5e19b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -605,6 +605,7 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 820018e3c..115caa033 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -200,6 +200,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-proje }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( + "imported thread shell projection", + (it) => { + it.effect("does not mark imported user messages as queued work in thread shells", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("import:codex:shell-session"); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-import-shell-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-import-shell"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-import-shell-message"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-message"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:shell-session:0"), + role: "user", + text: "Imported user prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>` + SELECT latest_user_message_at AS "latestUserMessageAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + + const sessionEvent = yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-import-shell-session"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-session"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-session"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }, + }); + yield* projectionPipeline.projectEvent(sessionEvent); + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 2f42126f8..7518d725e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,10 +1,12 @@ import { ApprovalRequestId, + isImportedAgentSessionMessageId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -229,7 +231,7 @@ function retainProjectionMessagesAfterRevert( } for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) { retainedMessageIds.add(message.messageId); continue; } @@ -239,7 +241,10 @@ function retainProjectionMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -252,7 +257,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingUserCount); @@ -262,7 +267,10 @@ function retainProjectionMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -275,7 +283,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingAssistantCount); @@ -896,6 +904,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.occurredAt, latestUserMessageAt: event.payload.role === "user" && + !isImportedAgentSessionMessageId(event.payload.messageId) && (previousLatest === null || event.payload.createdAt > previousLatest) ? event.payload.createdAt : previousLatest, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 92bf179f9..0ded33b4d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,4 +1,5 @@ import { + type AgentSessionImportSource, ChatAttachment, CheckpointRef, EventId, @@ -2531,7 +2532,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = // // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) // and a turnless activity at T03.6 — both belong to the page containing T03+. - const seedFanOutThread = Effect.fnUntraced(function* () { + const seedFanOutThread = Effect.fnUntraced(function* (options?: { + readonly importedMessageCount?: number; + }) { const sql = yield* SqlClient.SqlClient; // Tests in this block share one in-memory database; reset before seeding. @@ -2560,6 +2563,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) `; + if (options?.importedMessageCount) { + for (let index = 0; index < options.importedMessageCount; index += 1) { + const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`; + const role = index % 2 === 0 ? "user" : "assistant"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0, + '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z') + `; + } + } + const turns: ReadonlyArray<{ turn: string; pendingMessage: string | null; @@ -2793,6 +2810,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("keeps imported history on the oldest page after resumed turns", () => + Effect.gen(function* () { + yield* seedFanOutThread({ importedMessageCount: 12 }); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 }); + assert.equal(completePage._tag, "Some"); + if (completePage._tag !== "Some") return; + assert.equal( + completePage.value.thread.messages.filter((message) => message.id.startsWith("import:")) + .length, + 12, + ); + assert.equal(completePage.value.page?.hasMore, false); + assert.equal(completePage.value.page?.beforeCursor, null); + + const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(recentPage._tag, "Some"); + if (recentPage._tag !== "Some") return; + assert.equal( + recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")), + false, + ); + const cursor = recentPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(oldestPage._tag, "Some"); + if (oldestPage._tag !== "Some") return; + + const importedIds = oldestPage.value.thread.messages + .map((message) => message.id) + .filter((messageId) => messageId.startsWith("import:")); + assert.equal(importedIds.length, 12); + assert.equal(new Set(importedIds).size, 12); + assert.equal(oldestPage.value.page?.hasMore, false); + assert.equal(oldestPage.value.page?.beforeCursor, null); + }), + ); + it.effect("a cursor for a different thread degrades to the first page", () => Effect.gen(function* () { yield* seedFanOutThread(); @@ -3184,3 +3246,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); }); + +projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { + const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const source: AgentSessionImportSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex-home"), + providerSessionId: "native-session", + filePath: "/tmp/transcript.jsonl", + size: 128, + mtimeMs: 1_700_000_000_000, + device: 1, + inode: 2, + birthtimeMs: 1_699_000_000_000, + }; + + const seedImportedSession = Effect.fn("seedImportedSession")(function* ( + projectId: ProjectId, + source: AgentSessionImportSource, + ) { + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const timestamp = "2026-03-02T00:00:00.000Z"; + yield* sql` + INSERT OR IGNORE INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + created_at, updated_at + ) VALUES (${threadId}, ${projectId}, 'Imported thread', + ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })}, + 'full-access', 'default', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId}, + ${source.provider}, 'full-access', 'stopped', ${timestamp}, + ${encodeJson({ threadId: source.providerSessionId })}, + ${encodeJson({ importedTranscripts: [source] })}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0, + ${timestamp}, ${timestamp}) + `; + return { threadId, source }; + }); + + it.effect("reads completed source copies without decoding message bodies", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-metadata"); + const imported = yield* seedImportedSession(projectId, source); + const copiedSource = { + ...source, + filePath: "/tmp/transcript-copy.jsonl", + mtimeMs: null, + inode: null, + birthtimeMs: null, + }; + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + cwd: "/tmp/imported-project", + importedTranscripts: [source, copiedSource], + })} + WHERE thread_id = ${imported.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET attachments_json = 'not-json' + WHERE thread_id = ${imported.threadId} + `; + + const counter = makeSqlStatementCounter(); + const sources = yield* query + .getImportedAgentSessionSources(projectId) + .pipe(Effect.withTracer(counter.tracer)); + assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]); + assert.equal(counter.count(), 1); + }), + ); + + it.effect("requires active project threads, a binding, and an imported message", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-completion"); + const completed = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "completed", + }); + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`} + WHERE thread_id = ${completed.threadId} + `; + const partials = yield* Effect.forEach( + [ + "no-binding", + "no-history", + "no-imported-message", + "wrong-message-thread", + "archived", + "deleted", + ], + (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }), + ); + const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] = + partials; + assert.isDefined(noBinding); + assert.isDefined(noHistory); + assert.isDefined(noImportedMessage); + assert.isDefined(wrongMessageThread); + assert.isDefined(archived); + assert.isDefined(deleted); + yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`; + yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`; + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`} + WHERE thread_id = ${noImportedMessage.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET thread_id = 'unrelated-thread' + WHERE thread_id = ${wrongMessageThread.threadId} + `; + yield* sql` + UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${archived.threadId} + `; + yield* sql` + UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${deleted.threadId} + `; + const otherProjectId = ProjectId.make("project-import-other"); + const otherProject = yield* seedImportedSession(otherProjectId, { + ...source, + providerSessionId: "other-project", + }); + const deletedProjectId = ProjectId.make("project-import-deleted"); + yield* seedImportedSession(deletedProjectId, { + ...source, + providerSessionId: "deleted-project", + }); + yield* sql` + UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE project_id = ${deletedProjectId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []); + assert.deepEqual( + yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")), + [], + ); + }), + ); + + it.effect("keeps original sources when the current runtime provider and cursor change", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-switched"); + const imported = yield* seedImportedSession(projectId, { + ...source, + provider: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-original"), + providerSessionId: "original-session", + }); + yield* sql` + UPDATE provider_session_runtime + SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex', + resume_cursor_json = '{"threadId":"new-session"}' + WHERE thread_id = ${imported.threadId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]); + }), + ); + + it.effect("skips invalid source payloads and entries without dropping valid sources", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-invalid"); + const imported = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "a-invalid", + }); + const valid = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "z-valid", + }); + for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) { + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = ${payload} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + } + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = X'FF' + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + importedTranscripts: [ + null, + {}, + { ...imported.source, size: -1 }, + { ...imported.source, provider: "cursor" }, + { ...imported.source, providerInstanceId: "wrong-instance" }, + { ...imported.source, providerSessionId: "wrong-session" }, + imported.source, + ], + })} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6a7ea8465..97b2e2a7b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + AgentSessionImportSource, ApprovalRequestId, ChatAttachment, CheckpointRef, @@ -79,6 +80,14 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + importedTranscripts: Schema.Array(Schema.Unknown), + }), + ), +); +const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource); // Keep detail reads consistent with the in-memory projector's retained // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. @@ -195,6 +204,10 @@ const WorkspaceRootLookupInput = Schema.Struct({ const ProjectIdLookupInput = Schema.Struct({ projectId: ProjectId, }); +const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ + threadId: ThreadId, + runtimePayload: Schema.Unknown, +}); const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); @@ -1235,6 +1248,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listImportedAgentSessionSourceRows = SqlSchema.findAll({ + Request: ProjectIdLookupInput, + Result: ProjectionImportedAgentSessionSourcesRowSchema, + execute: ({ projectId }) => + sql` + SELECT + threads.thread_id AS "threadId", + runtime.runtime_payload_json AS "runtimePayload" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + INNER JOIN provider_session_runtime AS runtime + ON runtime.thread_id = threads.thread_id + WHERE threads.project_id = ${projectId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.message_id GLOB 'import:*' + ) + ORDER BY threads.thread_id ASC + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -3193,6 +3233,33 @@ pending_approval_requests AS ( Effect.map(Option.map((row) => row.threadId)), ); + const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] = + Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) { + const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getImportedAgentSessionSources:query", + "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows", + ), + ), + ); + return rows.flatMap((row) => { + const payload = decodeImportedTranscriptsPayload(row.runtimePayload); + if (Option.isNone(payload)) return []; + return payload.value.importedTranscripts.flatMap((entry) => { + const source = decodeAgentSessionImportSource(entry); + if ( + Option.isNone(source) || + row.threadId !== + `import:${source.value.providerInstanceId}:${source.value.providerSessionId}` + ) { + return []; + } + return [{ threadId: row.threadId, source: source.value }]; + }); + }); + }); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -3742,17 +3809,35 @@ pending_approval_requests AS ( ); const oldest = windowRows[0]; + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; // An empty window (no turns before the cursor, or a thread with no // turns at all) still returns thread metadata with empty collections // for turn-linked rows; turnless rows are bounded to the same empty // range. The first page of a turnless thread stays unwindowed so - // pre-turn content (e.g. a just-created thread) is not hidden. + // pre-turn content (e.g. a just-created thread) is not hidden. Once + // paging reaches the oldest turn, include turnless messages before + // the first turn, such as history imported from a provider session. const bounds: ThreadDetailBounds | undefined = oldest === undefined && cursor === null ? undefined : { - minAnchorAt: oldest?.anchorAt ?? "", - minTurnKey: oldest?.turnKey ?? "", + minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "", + minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "", beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, beforeTurnKey: cursor?.beforeTurnId ?? "", }; @@ -3769,23 +3854,6 @@ pending_approval_requests AS ( return Option.none(); } - const hasMore = - oldest !== undefined && - (yield* listTurnWindowRows({ - threadId, - beforeAnchorAt: oldest.anchorAt, - beforeTurnKey: oldest.turnKey, - userTurnLimit: 1, - maxRawTurns: 1, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", - ), - ), - )).length > 0; - const { snapshotSequence } = yield* getSnapshotSequence(); const watermarkRow = yield* getThreadEventWatermarkRow({ threadId, @@ -3846,6 +3914,7 @@ pending_approval_requests AS ( getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getImportedAgentSessionSources, getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 9f4fb50a5..8e392af31 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ * @module ProjectionSnapshotQuery */ import type { + AgentSessionImportSource, ApprovalRequestId, CheckpointRef, CommandId, @@ -197,6 +198,15 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read completed import sources without loading thread history. */ + readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }>, + ProjectionRepositoryError + >; + /** * Read the checkpoint context needed to resolve a single thread diff. */ diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts new file mode 100644 index 000000000..c809c7338 --- /dev/null +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -0,0 +1,509 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +it.layer(NodeServices.layer)("thread history import", (it) => { + it.effect("marks imported thread creation without changing live creation", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const projectId = ProjectId.make("project-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: createdAt, + commandId: CommandId.make("command-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-project-created"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + const makeCreateCommand = (threadId: ThreadId) => ({ + type: "thread.create" as const, + commandId: CommandId.make(`command-create-${threadId}`), + threadId, + projectId, + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt, + }); + + const imported = yield* decideOrchestrationCommand({ + command: { + ...makeCreateCommand(ThreadId.make("import:codex:session-1")), + historyImport: true, + }, + readModel, + }); + const live = yield* decideOrchestrationCommand({ + command: makeCreateCommand(ThreadId.make("live-thread")), + readModel, + }); + + expect(imported).toMatchObject({ + type: "thread.created", + metadata: { historyImport: true }, + }); + expect(live).toMatchObject({ type: "thread.created" }); + expect(live).not.toMatchObject({ metadata: { historyImport: true } }); + }), + ); + + it.effect("settles imported messages at the latest absolute timestamp", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:30:00.000+02:00"; + const threadId = ThreadId.make("import:codex:session-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const events = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-import-history"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt, + }, + { + messageId: MessageId.make(`${threadId}:000001`), + role: "assistant", + text: "Fixed", + createdAt: "2026-08-24T09:00:00.000Z", + }, + ], + }, + readModel, + }); + + expect(events).toMatchObject([ + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false }, + }, + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false }, + }, + { + type: "thread.settled", + metadata: { historyImport: true }, + occurredAt: "2026-08-24T09:00:00.000Z", + payload: { + settledAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", + }, + }, + ]); + + let projected = readModel; + const plannedEvents = Array.isArray(events) ? events : [events]; + for (const [index, event] of plannedEvents.entries()) { + projected = yield* projectEvent(projected, { ...event, sequence: index + 2 }); + } + projected = yield* projectEvent(projected, { + sequence: 5, + eventId: EventId.make("event-import-reverted"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.reverted", + occurredAt: "2026-08-24T10:02:00.000Z", + commandId: CommandId.make("command-import-reverted"), + causationEventId: null, + correlationId: CommandId.make("command-import-reverted"), + metadata: {}, + payload: { threadId, turnCount: 0 }, + }); + expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([ + "Fix the bug", + "Fixed", + ]); + }), + ); + + it.effect("allows a thread with a newly imported user message to be settled", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z")); + const threadId = ThreadId.make("import:codex:session-1"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-import-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-import-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-import-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-import-user-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: createdAt, + commandId: CommandId.make("command-import-user-message"), + causationEventId: null, + correlationId: CommandId.make("command-import-user-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:session-1:0"), + role: "user", + text: "Existing prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("command-settle-imported-thread"), + threadId, + }, + readModel, + }); + + expect(result).toMatchObject({ type: "thread.settled" }); + }), + ); + + it.effect("rejects history import after a client message reaches the thread", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const liveMessageAt = "2026-08-24T10:02:00.000Z"; + const threadId = ThreadId.make("import:codex:client-race"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-client-race-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-client-race-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-client-race-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: liveMessageAt, + commandId: CommandId.make("command-client-race-message"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-message"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("client-race-message"), + role: "user", + text: "Start live work", + turnId: null, + streaming: false, + createdAt: liveMessageAt, + updatedAt: liveMessageAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-client-race-import"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt); + }), + ); + + for (const requestKind of ["approval.requested", "user-input.requested"] as const) { + it.effect(`rejects history import with an open ${requestKind} activity`, () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make(`import:codex:${requestKind}`); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make(`event-${requestKind}-thread-created`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}-thread-created`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}-thread-created`), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make(`event-${requestKind}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.activity-appended", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}`), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make(`activity-${requestKind}`), + tone: "approval", + kind: requestKind, + summary: "Pending request", + payload: { requestId: "request-1" }, + turnId: null, + createdAt, + }, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make(`command-import-${requestKind}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + }), + ); + } + + it.effect("rejects a live user message in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("command-live-import-id"), + threadId, + message: { + messageId: MessageId.make("import:forged-live-message"), + role: "user", + text: "Live work", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + }), + ); + + it.effect("rejects live assistant messages in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-assistant-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-assistant-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-assistant-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-assistant-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + for (const commandType of [ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ] as const) { + const command = + commandType === "thread.message.assistant.delta" + ? { + type: commandType, + commandId: CommandId.make("command-live-assistant-delta-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + delta: "Live work", + createdAt, + } + : { + type: commandType, + commandId: CommandId.make("command-live-assistant-complete-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + createdAt, + }; + const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel })); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ae6c0ed3c..89857b6d1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -5,12 +5,14 @@ import { ThreadLinkedPullRequest, MessageId, UserInputRequestedPayload, + isImportedAgentSessionMessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, type OrchestrationThreadActivity, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -114,7 +116,7 @@ function hasQueuedTurnStartForThread( let latestUserMessageAt: string | null = null; let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; for (const message of thread.messages) { - if (message.role !== "user") continue; + if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; const messageAtMs = Date.parse(message.createdAt); latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); if (messageAtMs === latestUserMessageAtMs) { @@ -378,6 +380,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, + ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), })), type: "thread.created", payload: { @@ -1107,6 +1110,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.start": { + if (isImportedAgentSessionMessageId(command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, + }); + } const targetThread = yield* requireThread({ readModel, command, @@ -2052,6 +2061,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.delta": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -2079,6 +2094,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.complete": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -2105,6 +2126,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.history.import": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.messages.length > 0 || + thread.latestTurn !== null || + thread.session !== null || + openRequests({ ...thread, activities: pendingRequestActivities ?? thread.activities }) + .size > 0 + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, + }); + } + const firstMessage = command.messages[0]; + if (firstMessage === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread history imports require at least one message.", + }); + } + + const events: Array = []; + for (const message of command.messages) { + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: message.createdAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + const settledAt = command.messages.reduce( + (latest, message) => + compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, + firstMessage.createdAt, + ); + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: settledAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: settledAt, + }, + }); + return events; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 6368a6328..2cdaa47cd 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,10 +1,12 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; @@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert( ): ReadonlyArray { const retainedMessageIds = new Set(); for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { retainedMessageIds.add(message.id); continue; } @@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { @@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 53ab4d773..12f4db91f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,12 +12,24 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { - it.effect("finds the latest user-message time within one thread", () => + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-latest-user-message"); assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + yield* repository.upsert({ + messageId: MessageId.make("import:codex:latest-user-message:000000"), + threadId, + turnId: null, + role: "user", + text: "Imported prompt", + isStreaming: false, + createdAt: "2026-02-28T19:05:06.000Z", + updatedAt: "2026-02-28T19:05:06.000Z", + }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + const messages = [ { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index c612d56f0..eae7189de 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -210,6 +210,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { SELECT MAX(created_at) AS "latestUserMessageAt" FROM projection_thread_messages WHERE thread_id = ${threadId} AND role = 'user' + AND message_id NOT GLOB 'import:*' `, }); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd8625..d73f56aab 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { + AgentSessionImportSource, IsoDateTime, ProviderInstanceId, ProviderSessionRuntimeStatus, @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; +export const RecordImportedTranscriptInput = Schema.Struct({ + threadId: ThreadId, + source: AgentSessionImportSource, +}); +export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type; + +export interface ProviderSessionRuntimeUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< /** * Insert or replace a provider runtime row. * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, retaining imported transcript records + * from the current database row. */ readonly upsert: ( runtime: ProviderSessionRuntime, + options?: ProviderSessionRuntimeUpsertOptions, + ) => Effect.Effect; + + /** Record one source file without replacing the current session state. */ + readonly recordImportedTranscript: ( + input: RecordImportedTranscriptInput, ) => Effect.Effect; /** @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; +const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields( + Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }), +); + function toPersistenceSqlOrDecodeError( sqlOperation: string, decodeOperation: string, @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Runtime writes can carry stale payloads. Only recordImportedTranscript may + // change source records, so restore that field from the row being updated. const upsertRuntimeRow = SqlSchema.void({ Request: ProviderSessionRuntimeDbRowSchema, execute: (runtime) => @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, - ${runtime.runtimePayload} + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END ) ON CONFLICT (thread_id) DO UPDATE SET @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json + runtime_payload_json = CASE + WHEN json_type( + CASE + WHEN json_valid(provider_session_runtime.runtime_payload_json) + THEN provider_session_runtime.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts' + ) IS NOT NULL + THEN json_set( + CASE + WHEN json_type(excluded.runtime_payload_json) = 'object' + THEN excluded.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts', + json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts') + ) + ELSE excluded.runtime_payload_json + END + `, + }); + + const insertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END + ) + ON CONFLICT (thread_id) DO NOTHING + `, + }); + + const recordImportedTranscriptRow = SqlSchema.void({ + Request: RecordImportedTranscriptRequestSchema, + execute: ({ threadId, source }) => + sql` + WITH current_runtime AS ( + SELECT CASE + WHEN json_valid(runtime_payload_json) THEN CASE + WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json + ELSE '{}' + END + ELSE '{}' + END AS payload + FROM provider_session_runtime + WHERE thread_id = ${threadId} + ) + UPDATE provider_session_runtime + SET runtime_payload_json = ( + SELECT json_set( + payload, + '$.importedTranscripts', + json(( + SELECT json_group_array(json(value)) + FROM ( + SELECT value + FROM json_each(CASE + WHEN json_type(payload, '$.importedTranscripts') = 'array' + THEN json_extract(payload, '$.importedTranscripts') + ELSE '[]' + END) + WHERE CASE + WHEN type = 'object' THEN + json_extract(value, '$.providerInstanceId') + IS NOT json_extract(${source}, '$.providerInstanceId') + OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath') + ELSE 0 + END + UNION ALL + SELECT ${source} AS value + ) + )) + ) + FROM current_runtime + ) + WHERE thread_id = ${threadId} `, }); @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { `, }); - const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) => + (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] = + (input) => + recordImportedTranscriptRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.recordImportedTranscript:query", + "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest", + { threadId: input.threadId }, + ), + ), + ); + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => getRuntimeRowByThreadId(input).pipe( Effect.mapError( @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getByThreadId, list, deleteByThreadId, diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts new file mode 100644 index 000000000..95287e786 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -0,0 +1,1237 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { + AgentSessionImportProjectChangedError, + CheckpointRef, + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThread, + type ProviderSendTurnInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; +import { ServerConfig } from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; +import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const WORKSPACE_ROOT = "/tmp/project-from-server"; +const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ + source, + providerInstanceId: ProviderInstanceId.make(source), + providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, + title: `Imported ${source} thread`, + model: null, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:01:00.000Z", + messages: [ + { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" }, + { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" }, + ], +}); + +const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) => + ({ + _tag: "Importable", + thread, + source: { + provider: thread.source, + providerInstanceId: thread.providerInstanceId, + providerSessionId: thread.providerSessionId, + filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`, + size: 0, + mtimeMs: 0, + device: 0, + inode: 0, + birthtimeMs: 0, + }, + }) satisfies AgentSessionScanner.AgentSessionRecentThread; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", +}); + +const makeProjectedThread = (input: { + readonly source: "codex" | "claudeAgent"; + readonly projectId?: ProjectId; + readonly imported?: boolean; + readonly includeFollowup?: boolean; +}): OrchestrationThread => { + const sourceThread = makeThread(input.source); + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + return { + id: threadId, + projectId: input.projectId ?? PROJECT_ID, + title: sourceThread.title, + modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: sourceThread.createdAt, + updatedAt: sourceThread.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: input.imported + ? [ + { + id: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:00:00.000Z", + }, + ...(input.includeFollowup + ? [ + { + id: MessageId.make("user-followup"), + role: "user" as const, + text: "Keep going", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:02:00.000Z", + updatedAt: "2026-08-24T10:02:00.000Z", + }, + ] + : []), + ] + : [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +}; + +const makeSnapshotsLayer = (input: { + readonly project?: OrchestrationProjectShell; + readonly getThread?: (threadId: ThreadId) => Option.Option; +}) => + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getProjectShellById: () => + Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)), + getImportedAgentSessionSources: () => Effect.succeed([]), + getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()), + }); + +const runImport = (input: { + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"]; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly snapshots: ReturnType; + readonly expectedWorkspaceRoot?: string; +}) => + importRecentAgentThreads({ + projectId: PROJECT_ID, + ...(input.expectedWorkspaceRoot === undefined + ? {} + : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }), + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provide(input.snapshots), + ); + +it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { + describe("importRecentAgentThreads", () => { + it.effect("uses the project root and stores provider-specific resume cursors", () => + Effect.gen(function* () { + const commands: Array = []; + const bindings: Array = []; + let scannedRoot: string | undefined; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: (workspaceRoot) => { + scannedRoot = workspaceRoot; + return Stream.concat( + Stream.succeed(makeThreadOutcome(makeThread("codex"))), + Stream.fromEffect( + Effect.sync(() => { + expect(bindings).toHaveLength(1); + return makeThreadOutcome(makeThread("claudeAgent")); + }), + ), + ); + }, + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => Effect.sync(() => void bindings.push(binding)), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + removeExact: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`, + }); + + expect(result).toEqual({ importedCount: 2, skippedCount: 0 }); + expect(scannedRoot).toBe(WORKSPACE_ROOT); + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.history.import", + "thread.create", + "thread.history.import", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([ + { historyImport: true }, + { historyImport: true }, + ]); + expect( + commands + .filter((command) => command.type === "thread.history.import") + .flatMap((command) => command.messages.map((message) => message.messageId)), + ).toEqual([ + "import:codex:codex-session:000000", + "import:codex:codex-session:000001", + `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`, + `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`, + ]); + expect(bindings).toMatchObject([ + { + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + { + provider: "claudeAgent", + providerInstanceId: "claudeAgent", + resumeCursor: { + threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`, + resume: CLAUDE_SESSION_ID, + }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + ]); + }), + ); + + it.effect("rejects a changed project root before scanning or writing", () => + Effect.gen(function* () { + const recentThreads = vi.fn(() => Stream.empty); + const error = yield* importRecentAgentThreads({ + projectId: PROJECT_ID, + expectedWorkspaceRoot: WORKSPACE_ROOT, + }).pipe( + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("must not scan a changed project"), + recentThreads, + }), + ), + Effect.provide( + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}), + makeSnapshotsLayer({ + project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" }, + }), + ), + ), + Effect.flip, + ); + + expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })); + expect(recentThreads).not.toHaveBeenCalled(); + }), + ); + + it.effect("counts scanner skips without writing a thread or binding", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed({ _tag: "Skipped" }), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not dispatch for a scanner skip"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind a scanner skip"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.die("must not read a scanner skip binding"), + removeExact: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); + }), + ); + + it.effect("recovers after a rejected history receipt and a failed binding write", () => + Effect.gen(function* () { + let threadCreated = false; + let historyImported = false; + let historyAttemptCount = 0; + let bindingAttemptCount = 0; + const rejectedCommandIds = new Set(); + const bindings: Array = []; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => { + if (rejectedCommandIds.has(command.commandId)) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Previously rejected.", + }), + ); + } + if (command.type === "thread.create") threadCreated = true; + if (command.type === "thread.history.import") { + historyAttemptCount += 1; + if (historyAttemptCount === 1) { + rejectedCommandIds.add(command.commandId); + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Temporary history import failure.", + }), + ); + } + historyImported = true; + } + return Effect.succeed({ sequence: 1 }); + }, + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => { + bindingAttemptCount += 1; + if (bindingAttemptCount === 1) { + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "upsert", + detail: "Temporary session storage failure.", + }), + ); + } + bindings.push(binding); + return Effect.void; + }, + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => + Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), + listThreadIds: () => Effect.die("unused"), + removeExact: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const snapshots = makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + threadCreated + ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported })) + : Option.none(), + }); + const importOnce = () => runImport({ scanner, engine, directory, snapshots }); + + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + const historyAttemptsAfterCompletion = historyAttemptCount; + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion); + expect(historyAttemptCount).toBe(2); + expect(bindings).toHaveLength(1); + }), + ); + + it.effect("does not replace completed history or an active binding on retry", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: ThreadId.make("import:codex:codex-session"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "newer-codex-session" }, + }; + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not replace an active binding"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.some(runningBinding)), + removeExact: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not replay history or settle active work"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + Option.some( + makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }), + ), + }), + }); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + }), + ); + + it.effect("skips malformed Claude ids and wrong-project thread collisions", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.fromIterable([ + makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }), + makeThreadOutcome(makeThread("codex")), + ]), + }); + const commands: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.succeed(Option.none()), + removeExact: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: (threadId) => + threadId === "import:codex:codex-session" + ? Option.some( + makeProjectedThread({ + source: "codex", + projectId: ProjectId.make("project-other"), + }), + ) + : Option.none(), + }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 2 }); + expect(commands).toHaveLength(0); + }), + ); + }); +}); + +const integrationThread = { + ...makeThread("codex"), + updatedAt: "2026-08-24T10:00:00.000Z", + messages: Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `Message ${index}`, + createdAt: "2026-08-24T10:00:00.000Z", + })), +}; +const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), +}); +const integrationServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-agent-session-importer-test-", +}); +const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), +); +const integrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + integrationRuntimeRepository, + ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)), + Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner), +).pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(integrationServerConfig), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { + it.effect("imports once after the real engine persists an old rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:codex-session"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-integration-project"), + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + const rejected = yield* Effect.result( + engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`agent-session:history:${threadId}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }), + ); + expect(rejected._tag).toBe("Failure"); + + const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID }); + const importedThread = yield* snapshots.getThreadDetailById(threadId); + const binding = yield* directory.getBinding(threadId); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled"); + expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z"); + expect(Option.getOrThrow(binding)).toMatchObject({ + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }); + + // Pylon's rollback completion is a revision compare-and-set, so the + // revert needs a completed turn after the imported history to undo. + yield* engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("complete-turn-after-import"), + threadId, + turnId: TurnId.make("turn-after-import"), + completedAt: "2026-08-24T10:04:00.000Z", + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/import/turn/1"), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt: "2026-08-24T10:04:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.revert.complete", + commandId: CommandId.make("revert-imported-thread-to-baseline"), + threadId, + operationId: "revert-imported-thread-to-baseline", + sourceRevision: 1, + targetRevision: 0, + turnCount: 0, + createdAt: "2026-08-24T10:05:00.000Z", + }); + const afterRevert = yield* snapshots.getThreadDetailById(threadId); + expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + }), + ); + + it.effect( + "retries a bounded import after scanner restart without rereading completed transcripts", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-import-retry-", + }); + const workspaceRoot = path.join(fixtureDir, "workspace"); + const claudeHomePath = path.join(fixtureDir, "claude"); + const codexHomePath = path.join(fixtureDir, "codex"); + const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* fileSystem.makeDirectory(workspaceRoot); + yield* fileSystem.makeDirectory(claudeHomePath); + yield* fileSystem.makeDirectory(sessionsDir, { recursive: true }); + + const projectId = ProjectId.make("project-bounded-import-retry"); + const transcripts = Array.from({ length: 101 }, (_, index) => { + const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`; + return { + providerSessionId, + threadId: ThreadId.make(`import:codex:${providerSessionId}`), + filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`), + }; + }); + for (const [index, transcript] of transcripts.entries()) { + yield* fileSystem.writeFileString( + transcript.filePath, + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: transcript.providerSessionId, cwd: workspaceRoot }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: `Prompt ${transcript.providerSessionId}`, + }, + }), + ].join("\n"), + ); + const seconds = nowMs / 1_000 - index; + yield* fileSystem.utimes(transcript.filePath, seconds, seconds); + } + const legacy = transcripts[0]!; + const failed = transcripts[1]!; + const remaining = transcripts[100]!; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-bounded-import-project"), + projectId, + title: "Bounded import", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + // This completed import predates persisted transcript source metadata. + yield* directory.upsert({ + threadId: legacy.threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "legacy-current-session" }, + runtimePayload: { cwd: workspaceRoot }, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-legacy-bounded-import"), + threadId: legacy.threadId, + projectId, + title: "Legacy import", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + historyImport: true, + }); + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("import-legacy-bounded-history"), + threadId: legacy.threadId, + messages: [ + { + messageId: MessageId.make(`${legacy.threadId}:000000`), + role: "user", + text: "Legacy imported history", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }); + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]); + + let failHistory = true; + const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({ + ...engine, + dispatch: (command) => { + if ( + failHistory && + command.type === "thread.history.import" && + command.threadId === failed.threadId + ) { + failHistory = false; + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Injected history import failure.", + }), + ); + } + return engine.dispatch(command); + }, + }); + const settingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { homePath: claudeHomePath }, + codex: { homePath: codexHomePath }, + }, + }); + const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath)); + const runAttempt = Effect.fn("runBoundedImportAttempt")(function* ( + completedPaths: ReadonlySet, + ) { + const openCounts = new Map(); + const fullReads: string[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => + Effect.suspend(() => { + if (transcriptPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + // A fresh scanner first opens each file for project discovery. + if (count > 1) { + fullReads.push(filePath); + if (completedPaths.has(filePath)) { + return Effect.die(new Error(`Completed transcript reopened: ${filePath}`)); + } + } + } + return fileSystem.open(filePath, options); + }), + }); + const result = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provide( + Layer.fresh(AgentSessionScanner.layer).pipe( + Layer.provide(settingsLayer), + Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)), + ), + ), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine), + ); + return { result, fullReads, openCounts }; + }); + + const first = yield* runAttempt(new Set()); + expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 }); + expect(failHistory).toBe(false); + expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath)); + expect(first.openCounts.get(remaining.filePath)).toBe(1); + const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId); + expect(completedSources).toHaveLength(99); + expect(completedSources).toContainEqual({ + threadId: legacy.threadId, + source: expect.objectContaining({ filePath: legacy.filePath }), + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages, + ).toEqual([]); + expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({ + status: "stopped", + resumeCursor: { threadId: failed.providerSessionId }, + }); + expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true); + + const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath)); + const second = yield* runAttempt(completedPaths); + expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 }); + expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]); + for (const transcript of transcripts) { + expect(second.openCounts.get(transcript.filePath)).toBe( + completedPaths.has(transcript.filePath) ? 1 : 2, + ); + } + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Legacy imported history"]); + expect( + Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor, + ).toEqual({ + threadId: "legacy-current-session", + }); + for (const transcript of [failed, remaining]) { + expect( + Option.getOrThrow( + yield* snapshots.getThreadDetailById(transcript.threadId), + ).messages.map((message) => message.text), + ).toEqual([`Prompt ${transcript.providerSessionId}`]); + } + }), + ); + + for (const source of ["codex", "claudeAgent"] as const) { + it.effect(`resumes imported ${source} history only after the first prompt`, () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped(); + const projectId = ProjectId.make(`project-import-resume-${source}`); + const sourceThread = { + ...makeThread(source), + providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID, + }; + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + const resumeCursor = + source === "codex" + ? { threadId: sourceThread.providerSessionId } + : { threadId, resume: sourceThread.providerSessionId }; + const provider = ProviderDriverKind.make(source); + const harness = yield* makeTestProviderAdapterHarness({ provider }); + const importSettled = yield* Deferred.make(); + const turnSent = yield* Deferred.make(); + const startSession = vi.fn(harness.adapter.startSession); + const sendTurn = vi.fn((input: ProviderSendTurnInput) => + harness.adapter + .sendTurn(input) + .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ + [provider]: { ...harness.adapter, startSession, sendTurn }, + }), + ), + ), + Layer.provide( + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide(AnalyticsService.layerTest), + ); + const reactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(providerLayer), + Layer.provide( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + ...snapshots, + // Acknowledge the imported settlement before draining the reactor. + getThreadShellById: (requestedThreadId) => + snapshots + .getThreadShellById(requestedThreadId) + .pipe( + Effect.tap(() => + requestedThreadId === threadId + ? Deferred.succeed(importSettled, undefined) + : Effect.void, + ), + ), + }), + ), + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: () => Effect.succeed(false), + }), + ), + Layer.provide(makeProviderRegistryLayer()), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), + Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(ServerSettingsService.layerTest()), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`create-import-resume-project-${source}`), + projectId, + title: "Import resume", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* harness.queueTurnResponseForNextSession({ events: [] }); + + yield* Effect.gen(function* () { + const reactor = yield* ProviderCommandReactor; + yield* reactor.start(); + expect(yield* importRecentAgentThreads({ projectId })).toEqual({ + importedCount: 1, + skippedCount: 0, + }); + yield* Deferred.await(importSettled); + yield* reactor.drain; + expect(startSession).not.toHaveBeenCalled(); + expect(sendTurn).not.toHaveBeenCalled(); + const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); + expect(importedThread.session).toBeNull(); + expect(importedThread.latestTurn).toBeNull(); + + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`resume-imported-${source}`), + threadId, + message: { + messageId: MessageId.make(`resume-imported-message-${source}`), + role: "user", + text: "Continue this session", + attachments: [], + }, + modelSelection: importedThread.modelSelection, + runtimeMode: importedThread.runtimeMode, + interactionMode: importedThread.interactionMode, + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.await(turnSent); + yield* reactor.drain; + expect(startSession).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + threadId, + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + cwd: workspaceRoot, + }), + ); + expect(sendTurn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ threadId, input: "Continue this session" }), + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + }); + }).pipe( + Effect.provide(reactorLayer), + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), + }), + ), + ); + }), + ); + } + + it.effect("persists the resume cursor before publishing a new imported thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-binding-race"); + const workspaceRoot = "/tmp/project-import-binding-race"; + const providerSessionId = "codex-binding-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-binding-race-project"), + projectId, + title: "Binding race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + + yield* Effect.raceFirst( + Deferred.await(importerAtBindingWrite), + Fiber.join(importFiber).pipe( + Effect.flatMap((result) => + Effect.die( + new Error(`Import completed before the binding write: ${JSON.stringify(result)}`), + ), + ), + ), + ); + expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(integrationThread.messages.map((message) => message.text)); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + }), + ); + + it.effect("does not import history over a turn started on a partial thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-turn-race"); + const workspaceRoot = "/tmp/project-import-turn-race"; + const providerSessionId = "codex-turn-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-turn-race-project"), + projectId, + title: "Turn race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-import-turn-race-thread"), + threadId, + projectId, + title: "Turn race", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + yield* Deferred.await(importerAtBindingWrite); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-turn-during-import"), + threadId, + message: { + messageId: MessageId.make("message-during-import"), + role: "user", + text: "Continue while import waits", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Continue while import waits"]); + }), + ); +}); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts new file mode 100644 index 000000000..ef7042ad6 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -0,0 +1,301 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionSource, + AgentSessionScanError, + isImportedAgentSessionMessageId, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, + type AgentSessionImportInput, + type AgentSessionImportResult, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const CLAUDE_SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()( + "AgentSessionUnresumableSessionError", + { + source: AgentSessionSource, + providerSessionId: Schema.String, + }, +) { + override get message(): string { + return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`; + } +} + +class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass()( + "AgentSessionThreadProjectConflictError", + { + threadId: ThreadId, + expectedProjectId: ProjectId, + actualProjectId: ProjectId, + }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`; + } +} + +class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass()( + "AgentSessionThreadModifiedError", + { threadId: ThreadId }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' changed before its history import completed.`; + } +} + +function hasImportedHistory(thread: OrchestrationThread): boolean { + return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id)); +} + +function hasImportBlockingActivity( + thread: OrchestrationThread, + importedHistoryPresent: boolean, +): boolean { + return ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.latestTurn !== null || + thread.session !== null || + thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) || + thread.proposedPlans.length > 0 || + thread.activities.length > 0 || + thread.checkpoints.length > 0 || + thread.snoozedUntil != null || + thread.snoozedAt != null || + thread.pinnedAt != null || + thread.pinOrderKey != null || + thread.titleRegeneration != null || + thread.linkedPullRequest != null || + thread.branchPullRequest != null || + thread.activeOrderKey != null || + thread.rollbackStatus != null || + thread.continuedFromThreadId != null || + thread.unsettledAt != null || + (importedHistoryPresent + ? thread.settledOverride !== "settled" + : thread.settledOverride !== null || thread.settledAt !== null) + ); +} + +/** Import recent transcript text and persist the cursor needed to resume its provider session. */ +export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( + input: AgentSessionImportInput, +) { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const crypto = yield* Crypto.Crypto; + const project = yield* snapshots.getProjectShellById(input.projectId).pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), + onSome: Effect.succeed, + }), + ), + ); + const workspaceRoot = project.workspaceRoot; + if ( + input.expectedWorkspaceRoot !== undefined && + normalizeProjectPathForComparison(workspaceRoot) !== + normalizeProjectPathForComparison(input.expectedWorkspaceRoot) + ) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + const completedSources = yield* snapshots + .getImportedAgentSessionSources(input.projectId) + .pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const threads = scanner.recentThreads( + workspaceRoot, + completedSources.map((entry) => entry.source), + ); + const importedThreadIds = new Set(); + let importedCount = 0; + let skippedCount = 0; + + yield* Stream.runForEach(threads, (outcome) => + Effect.gen(function* () { + if (outcome._tag === "Skipped") { + skippedCount += 1; + return; + } + if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") { + const threadId = ThreadId.make( + `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`, + ); + if (outcome._tag === "AlreadyImported") { + importedThreadIds.add(threadId); + importedCount += 1; + } else if (importedThreadIds.has(threadId)) { + const recorded = yield* directory + .recordImportedTranscript({ threadId, source: outcome.source }) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + skippedCount += 1; + yield* Effect.logWarning("Could not record an imported transcript copy", { + threadId, + cause: recorded.failure, + }); + } + } + return; + } + const thread = outcome.thread; + const threadId = ThreadId.make( + `import:${thread.providerInstanceId}:${thread.providerSessionId}`, + ); + const imported = yield* Effect.gen(function* () { + const provider = ProviderDriverKind.make(thread.source); + const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const existingThread = yield* snapshots.getThreadDetailById(threadId); + const existingBinding = yield* directory.getBinding(threadId); + + if ( + thread.source === "claudeAgent" && + !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId) + ) { + return yield* new AgentSessionUnresumableSessionError({ + source: thread.source, + providerSessionId: thread.providerSessionId, + }); + } + + if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { + return yield* new AgentSessionThreadProjectConflictError({ + threadId, + expectedProjectId: input.projectId, + actualProjectId: existingThread.value.projectId, + }); + } + + const importedHistoryPresent = Option.isSome(existingThread) + ? hasImportedHistory(existingThread.value) + : false; + if ( + Option.isSome(existingThread) && + importedHistoryPresent && + Option.isSome(existingBinding) + ) { + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + return true; + } + + if ( + Option.isSome(existingThread) && + hasImportBlockingActivity(existingThread.value, importedHistoryPresent) + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + if ( + Option.isSome(existingBinding) && + (existingBinding.value.provider !== provider || + existingBinding.value.providerInstanceId !== thread.providerInstanceId || + existingBinding.value.status !== "stopped") + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + // Install the cursor before the thread becomes visible. A concurrent + // real session can replace it, while insert-ignore keeps this import + // from replacing that newer binding. + if (Option.isNone(existingBinding)) { + yield* directory.upsert( + { + threadId, + provider, + providerInstanceId: thread.providerInstanceId, + status: "stopped", + runtimeMode: DEFAULT_RUNTIME_MODE, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + }, + { onConflict: "ignore" }, + ); + } + + if (Option.isNone(existingThread)) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: input.projectId, + title: thread.title, + modelSelection: { instanceId: thread.providerInstanceId, model }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: thread.createdAt, + historyImport: true, + }); + } + + if (!importedHistoryPresent) { + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + messages: thread.messages.map((message, index) => ({ + messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + }); + } + + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + + return true; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import an agent session", { + provider: thread.source, + sessionId: thread.providerSessionId, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (imported) { + importedThreadIds.add(threadId); + importedCount += 1; + } else { + skippedCount += 1; + } + }), + ); + + return { importedCount, skippedCount } satisfies AgentSessionImportResult; +}); diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts new file mode 100644 index 000000000..d31c47833 --- /dev/null +++ b/apps/server/src/project/AgentSessionJson.ts @@ -0,0 +1,155 @@ +import * as SchemaAST from "effect/SchemaAST"; +import { isMany, none, type Many } from "stream-chain/defs.js"; +import { Assembler } from "stream-json/core/assembler.js"; +import { filter } from "stream-json/core/filters/filter.js"; +import * as StreamJson from "stream-json/core/parser.js"; +import type { ParserOptions, Token } from "stream-json/core/parser.js"; + +type JsonPath = ReadonlyArray; + +/** Select schema fields before assembling their values, without a second field list. */ +export function createTranscriptJsonSelector(schema: { readonly ast: SchemaAST.AST }) { + const ast = SchemaAST.toEncoded(schema.ast); + const includes = (node: SchemaAST.AST, path: JsonPath, index: number): boolean => { + if (index === path.length) return true; + switch (node._tag) { + case "Objects": + // Records have dynamic keys. Keep their values for the decoder to validate. + if (node.indexSignatures.length > 0) return true; + return node.propertySignatures.some( + (property) => + String(property.name) === path[index] && includes(property.type, path, index + 1), + ); + case "Arrays": { + const key = path[index]; + if (typeof key !== "number") return true; + const element = node.elements[key]; + if (element) return includes(element, path, index + 1); + return node.rest.length === 0 || node.rest.some((item) => includes(item, path, index + 1)); + } + case "Union": + return node.types.some((type) => includes(type, path, index)); + case "Suspend": + return includes(node.thunk(), path, index); + case "Unknown": + case "Any": + case "ObjectKeyword": + case "Declaration": + // Unstructured/custom schemas must reach the decoder intact. The + // shared budget still bounds their allocations. + return true; + default: + return false; + } + }; + return (path: JsonPath) => includes(ast, path, 0); +} + +export class TranscriptJsonLimitError extends Error {} + +/** + * Project a single JSONL record without materializing unselected string values. + * The caller supplies a shared allocation budget for the entire transcript. + * Budget exhaustion rejects the transcript, never a message within it. + */ +export function createTranscriptJsonReader( + reserve: (bytes: number) => void, + selectPath: (path: JsonPath) => boolean, +) { + // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted + // from its bundled types. Unlike parser(), it does not wrap tokens in an + // async generator; the file reader already supplies backpressure and UTF-8. + const { jsonParser } = StreamJson as typeof StreamJson & { + jsonParser: ( + options: ParserOptions, + ) => (input: string | typeof none) => Many | typeof none; + }; + const tokenize = jsonParser({ packValues: false }); + const select = filter({ filter: selectPath, streamKeys: false }) as ( + input: Token | typeof none, + ) => Token | Many | typeof none; + const assembler = new Assembler(); + let key: string | null = null; + let value = ""; + let depth = 0; + let complete = false; + let malformed = false; + + const assemble = (token: Token) => { + reserve( + 64 + ("value" in token && typeof token.value === "string" ? token.value.length * 2 : 0), + ); + switch (token.name) { + case "startString": + case "startNumber": + value = ""; + break; + case "stringChunk": + case "numberChunk": + value += token.value; + break; + case "endString": + assembler.consume({ name: "stringValue", value }); + value = ""; + break; + case "endNumber": + assembler.consume({ name: "numberValue", value }); + value = ""; + break; + default: + assembler.consume(token); + } + }; + const selectToken = (token: Token | typeof none) => { + const selected = select(token); + if (selected === none) return; + if (isMany(selected)) { + for (const item of selected.values) assemble(item); + } else { + assemble(selected); + } + }; + const consume = (input: string | typeof none) => { + if (malformed) return; + try { + const tokens = tokenize(input); + if (tokens === none) return; + for (const token of tokens.values) { + if (token.name === "startObject" || token.name === "startArray") { + if (++depth > 128) + throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds 128 levels"); + } else if (token.name === "endObject" || token.name === "endArray") { + if (--depth === 0) complete = true; + } + // Charge keys before assembling them, including unknown names. Reject + // the transcript on exhaustion instead of silently shortening a key. + if (token.name === "startKey") { + key = ""; + } else if (token.name === "stringChunk" && key !== null) { + reserve(token.value.length * 2); + key += token.value; + } else if (token.name === "endKey") { + selectToken({ name: "keyValue", value: key ?? "" }); + key = null; + } else { + selectToken(token); + } + } + } catch (cause) { + if (cause instanceof Error && cause.message.startsWith("Parser ")) { + malformed = true; + } else { + throw cause; + } + } + }; + return { + write: (chunk: string) => consume(chunk), + finish: (): unknown => { + consume(none); + if (malformed || !complete) return undefined; + selectToken(none); + return assembler.done ? assembler.current : undefined; + }, + }; +} diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000..20c7279e1 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,3221 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { describe, expect, it } from "@effect/vitest"; +import { + type OrchestrationProjectShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** Only `getShellSnapshot` is exercised; the rest must not be called. */ +const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getPendingRequestActivities: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)), + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.succeed([]), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +interface ScannerTestInput { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; + readonly providerInstances?: ContractServerSettings["providerInstances"]; +} + +const makeScannerTestLayer = (input: ScannerTestInput) => + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + ...(input.providerInstances === undefined + ? {} + : { providerInstances: input.providerInstances }), + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ); + +const runScan = (input: ScannerTestInput) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(input.workspaceRoot).pipe( + Stream.runCollect, + Effect.map((outcomes) => Array.from(outcomes)), + ); + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + runRecentThreadOutcomes(input).pipe( + Effect.map((outcomes) => + outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { + const records = + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "record-limit-session", cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "First prompt" }, + }), + ].join("\n") + + "\n" + + "{}\n".repeat(99_998); + return overflow + ? records + + "\n" + + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Overflow prompt" }, + }) + + "\n" + : records; +} + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + git: null, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + git: null, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "does not open a non-file %s transcript", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const transcriptPath = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") + : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); + yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(transcriptOpenCount).toBe(0); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "stops %s directory reads at the discovery operation budget", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const discoveryRoot = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects") + : path.join(codexHomePath, "sessions"); + const emptyDirectories = Array.from( + { length: 20_001 }, + (_, index) => `empty-${index.toString().padStart(5, "0")}`, + ); + let directoryReadCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed(emptyDirectories); + } + if (path.dirname(directory) === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(directoryReadCount).toBe(20_000); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + git: null, + }, + ]); + }), + ); + + it.effect("returns the imported project ID through a realpath alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), + ); + + it.effect("matches a persisted project alias to a transcript realpath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), + ); + + it.effect("merges case aliases and preserves the persisted project path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: true, + git: null, + }, + ]); + }), + ); + + it.effect("keeps case variants distinct when the filesystem identities differ", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const backingUpper = yield* makeTempDir("t3code-backing-upper-"); + const backingLower = yield* makeTempDir("t3code-backing-lower-"); + const aliasParent = yield* makeTempDir("t3code-case-aliases-"); + const upperWorkspace = path.join(aliasParent, "Repo"); + const lowerWorkspace = path.join(aliasParent, "repo"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), + contents: claudeSessionLine(upperWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), + contents: claudeSessionLine(lowerWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem.stat( + filePath === upperWorkspace + ? backingUpper + : filePath === lowerWorkspace + ? backingLower + : filePath, + ), + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + upperWorkspace, + lowerWorkspace, + ]); + }), + ); + + it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); + const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); + const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), + contents: claudeSessionLine(legacyWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexInstanceHome, + "sessions", + "2026", + "03", + "01", + "rollout-instance.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeInstanceHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexInstanceHome }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("scans each distinct home across multiple instances once", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + for (const [home, cwd] of [ + [codexHomePath, workspace], + [otherCodexHome, otherWorkspace], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHomePath }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: otherCodexHome }, + }, + }, + }); + + expect(result.candidates).toHaveLength(2); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); + expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( + [workspace, otherWorkspace].sort(), + ); + }), + ); + + it.effect("honors provider instance home directory environment variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("ignores invalid provider instances while scanning the remaining providers", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: 123 }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("does not scan provider instances disabled by the envelope or config", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); + const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); + const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); + const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); + + for (const [home, workspace, session] of [ + [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], + [configDisabledHome, configWorkspace, "config-disabled"], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-envelope-disabled")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { homePath: envelopeDisabledHome }, + }, + [ProviderInstanceId.make("codex-config-disabled")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false, homePath: configDisabledHome }, + }, + }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("ignores relative working directories from malformed transcripts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), + contents: claudeSessionLine(path.relative(path.resolve(), workspace)), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes the home directory, temporary root, and T3 data directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + for (const [index, cwd] of [ + NodeOS.homedir(), + NodeOS.tmpdir(), + configBaseDir, + workspace, + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("excludes Pylon- and T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwds = [".t3", ".pylon-code", ".pylon-code-nightly"].map((homeName) => + path.join(claudeHomePath, homeName, "worktrees", "pylon", "wt-1"), + ); + for (const [index, worktreeCwd] of worktreeCwds.entries()) { + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes Codex scratch directories and Downloads", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + // The exclusions key off the real home directory, so these fixtures + // must live there. Each run owns a uniquely named subtree and removes + // only that subtree, never the shared Codex or Downloads parents. + const home = NodeOS.homedir(); + // Borrow a unique suffix from a scoped temp dir instead of reaching for + // Date.now or Math.random, which the Effect lint rejects. + const runId = path.basename(yield* makeTempDir("t3code-scanner-test-")); + const scratchRoot = path.join(home, "Documents", "Codex", runId); + const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation"); + const downloads = path.join(home, "Downloads", runId); + const keep = yield* makeTempDir("t3code-workspace-keep-"); + yield* fileSystem.makeDirectory(scratch, { recursive: true }); + yield* fileSystem.makeDirectory(downloads, { recursive: true }); + yield* Effect.addFinalizer(() => + Effect.all([ + fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore), + fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore), + ]), + ); + + for (const [index, cwd] of [scratch, downloads, keep].entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "09", + "01", + `rollout-${index}.jsonl`, + ), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); + }), + ); + + it.effect("skips linked git worktrees and reports the origin of real checkouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const worktree = yield* makeTempDir("t3code-workspace-worktree-"); + const plain = yield* makeTempDir("t3code-workspace-plain-"); + const noRemote = yield* makeTempDir("t3code-workspace-noremote-"); + const submodule = yield* makeTempDir("t3code-workspace-submodule-"); + + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* fileSystem.writeFileString( + path.join(repo, ".git", "config"), + '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + ); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`, + ); + yield* fileSystem.makeDirectory(path.join(noRemote, ".git")); + yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n"); + // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`. + const submoduleGitDir = path.join(repo, ".git", "modules", "vendor"); + yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(submoduleGitDir, "config"), + '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + ); + yield* fileSystem.writeFileString( + path.join(submodule, ".git"), + `gitdir: ${submoduleGitDir}\n`, + ); + + for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + ).toEqual([ + { + path: submodule, + git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + }, + { path: noRemote, git: { remoteKey: null, repository: null } }, + { path: plain, git: null }, + { + path: repo, + git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }, + }, + ]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("finds the cwd on a later line when the first records carry none", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + // Claude transcripts often open with records that have no cwd. + const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("reads a complete transcript record at the exact chunk boundary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace).split("\n")[0]!; + const prefix = '{"padding":"'; + const suffix = `",${record.slice(1)}`; + const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(contents).toHaveLength(32 * 1024); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("finds session metadata after a first record larger than one chunk", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), + contents: `${history}${claudeSessionLine(workspace)}`, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); + const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); + const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); + const directories = [ + path.join(claudeHomePath, "projects", "p"), + path.join(secondHome, "projects", "p"), + ]; + const templates = directories.map((directory) => path.join(directory, "template.jsonl")); + for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { + const record = encodeTranscriptRecord({ cwd: workspace }); + yield* writeTranscript({ + filePath: templates[index]!, + contents: + " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, + }); + } + const resolveFile = (filePath: string) => { + const index = directories.indexOf(path.dirname(filePath)); + return index === -1 ? filePath : templates[index]!; + }; + let reservedBytes = 0; + let opens = 0; + const requests: number[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + const index = directories.indexOf(directory); + return index === -1 + ? fileSystem.readDirectory(directory, options) + : Effect.succeed( + Array.from( + { length: index === 0 ? 32 : count - 32 }, + (_, item) => `session-${item}.jsonl`, + ), + ); + }, + stat: (filePath) => fileSystem.stat(resolveFile(filePath)), + open: (filePath, options) => { + if (!directories.includes(path.dirname(filePath))) + return fileSystem.open(filePath, options); + opens += 1; + return fileSystem.open(resolveFile(filePath), options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => { + reservedBytes += Number(size); + requests.push(Number(size)); + return file.readAlloc(size); + }, + })), + ); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + firstWorkspace, + secondWorkspace, + ]); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); + expect(result.truncated).toBe(count === 65 ? true : undefined); + expect(opens).toBe(64); + expect(reservedBytes).toBe(64 * 1024 * 1024); + expect(requests[0]).toBe(8 * 1024); + expect(Math.max(...requests)).toBe(8 * 1024); + }), + ); + + it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); + const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-short-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + const record = encodeTranscriptRecord({ cwd: workspace }); + const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; + const bytes = new TextEncoder().encode(contents); + yield* writeTranscript({ + filePath: template, + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let operations = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed( + Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), + ) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + operations += 1; + let offset = 0; + return fileSystem.open(template, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: () => + Effect.sync(() => { + operations += 1; + if (offset === bytes.length) return Option.none(); + return Option.some(bytes.subarray(offset, ++offset)); + }), + })), + ); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(operations).toBe(20_000); + expect(result.candidates[0]?.threadCount).toBe(50); + expect(result.truncated).toBe(count === 51 ? true : undefined); + }), + ); + + it.effect("bounds malformed metadata records without excluding another account", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-record-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + yield* writeTranscript({ + filePath: template, + contents: "x\n".repeat(1_001), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(secondHome, "projects", "p", "session.jsonl"), + contents: encodeTranscriptRecord({ cwd: workspace }), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let malformedOpens = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + malformedOpens += 1; + return fileSystem.open(template, options); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + expect(malformedOpens).toBe(100); + expect(result.truncated).toBe(true); + }), + ); + + it.effect.each([19_999, 20_000])( + "reports unfinished directory work for %s project directories", + (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); + const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); + const projectsDir = path.join(claudeHomePath, "projects"); + let reads = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === projectsDir) { + reads += 1; + return Effect.succeed( + Array.from({ length: count }, (_, index) => `project-${index}`), + ); + } + if (path.dirname(directory) === projectsDir) { + reads += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(reads).toBe(20_000); + expect(result.candidates).toEqual([]); + expect(result.truncated).toBe(count === 20_000 ? true : undefined); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); + + describe("recentThreads", () => { + it.effect.each([false, true])( + "counts terminal newlines correctly with record overflow=%s", + (overflow) => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); + const workspace = yield* makeTempDir("t3code-record-limit-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-records.jsonl"), + contents: makeRecordLimitTranscript(workspace, overflow), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-older.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "older-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Older prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs - 1_000, + }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual( + overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], + ); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? outcome.thread.messages.map((message) => message.text) + : [], + ), + ).toEqual([overflow ? "Older prompt" : "First prompt"]); + }), + ); + + it.effect("imports recent Claude and Codex sessions for the selected project only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), + contents: claudeTranscript(otherWorkspace, "claude-other"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-codex-recent.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "codex-recent", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { type: "user_message", message: "Review this code" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Looks good" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "codex-recent", + "claude-recent", + ]); + expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Review this code", "Looks good"], + ["Fix the project", "Done"], + ]); + }), + ); + + it.effect("imports history recorded with a case alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "user", + cwd: workspaceAlias, + sessionId: "case-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "Import case alias history" }, + }), + encodeTranscriptRecord({ + type: "assistant", + sessionId: "case-session", + timestamp: "2026-08-24T10:01:00.000Z", + message: { role: "assistant", content: "Imported" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); + }), + ); + + it.effect("keeps the provider instance that owns a custom session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const customHome = yield* makeTempDir("t3code-codex-custom-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "custom-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use my work account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: customHome }, + }, + }, + }); + + expect(threads[0]?.providerInstanceId).toBe("codex-work"); + }), + ); + + it.effect("suppresses duplicate session copies without reporting a skipped import", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "copied-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session once" }, + }), + ].join("\n"); + + for (const [name, mtimeMs] of [ + ["rollout-copy-a.jsonl", nowMs], + ["rollout-copy-b.jsonl", nowMs - 1], + ] as const) { + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), + contents, + mtimeMs, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "copied-session" }, + }); + }), + ); + + it.effect("streams large transcripts across providers without hiding projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); + const workspace = yield* makeTempDir("t3code-budget-workspace-"); + const transcriptPaths = new Set(); + for (const [index, source] of [ + "codex", + "claudeAgent", + "codex", + "claudeAgent", + "codex", + ].entries()) { + const sessionId = `budget-session-${index}`; + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ) + : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); + const contents = + source === "codex" + ? [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + : encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + message: { content: "Imported prompt" }, + }); + transcriptPaths.add(filePath); + yield* writeTranscript({ + filePath, + contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), + mtimeMs: nowMs - index * 1_000, + }); + } + + const opens = new Map(); + let fullReadBytes = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + const count = (opens.get(filePath) ?? 0) + 1; + opens.set(filePath, count); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => + !transcriptPaths.has(filePath) || count === 1 + ? file + : { + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.sync(() => { + if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; + }), + ), + ), + }, + ), + ); + }, + }); + const outcomes = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates[0]?.threadCount).toBe(5); + return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual([ + "Importable", + "Importable", + "Importable", + "Importable", + "Importable", + ]); + expect(fullReadBytes).toBe(80 * 1024 * 1024); + }), + ); + + it.effect("skips excessive records without blocking an older valid transcript", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); + const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); + for (const [sessionId, padding, mtimeMs] of [ + ["excessive", "\n".repeat(100_001), nowMs], + ["older", "", nowMs - 1_000], + ] as const) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ), + contents: + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + padding, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + }), + ); + + for (const source of ["claudeAgent", "codex"] as const) { + for (const replacement of [ + "same root", + "other root", + "symlink alias", + "other then same", + ] as const) { + it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( + `rechecks ${source} snapshot cwd after replacement with ${replacement}`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixture = yield* makeTempDir("t3code-replaced-cwd-"); + const workspace = path.join(fixture, "original"); + const otherWorkspace = path.join(fixture, "other"); + const alias = path.join(fixture, "alias"); + const claudeHomePath = path.join(fixture, "claude"); + const codexHomePath = path.join(fixture, "codex"); + yield* fileSystem.makeDirectory(workspace); + yield* fileSystem.makeDirectory(otherWorkspace); + if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ) + : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); + const makeContents = (cwd: string, text: string, laterCwd?: string) => + [ + ...(source === "codex" + ? [ + { type: "session_meta", payload: { id: "replacement-session", cwd } }, + { type: "event_msg", payload: { type: "user_message", message: text } }, + ] + : [ + { + type: "user", + cwd, + sessionId: "replacement-session", + message: { content: text }, + }, + ]), + ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"); + yield* writeTranscript({ + filePath, + contents: makeContents(workspace, "Original prompt"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const replacementCwd = + replacement === "symlink alias" + ? alias + : replacement === "same root" + ? workspace + : otherWorkspace; + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: makeContents( + replacementCwd, + "Replacement prompt", + replacement === "other then same" ? workspace : undefined, + ), + mtimeMs: nowMs, + }); + const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + if (replacement === "same root" || replacement === "symlink alias") { + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { messages: [{ text: "Replacement prompt" }] }, + }); + } else { + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + } + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + } + } + + it.effect("checks file identity and provider before skipping completed history", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); + const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); + const workspace = yield* makeTempDir("t3code-completed-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ); + const contents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath, + contents: contents("original-session"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + // Keep the old inode allocated while replacing the path with an equal-size file. + yield* fileSystem.open(filePath); + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: contents("replaced-session"), + mtimeMs: nowMs, + }); + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, + }); + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("imports visible history from a transcript with an oversized tool record", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcript = `${[ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "large-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this large session" }, + }), + ].join("\n")}\n${encodeTranscriptRecord({ type: "tool_result", data: "" }).padEnd( + 16 * 1024 * 1024 + 1, + " ", + )}`; + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), + contents: transcript, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toMatchObject([ + { + _tag: "Importable", + thread: { + providerSessionId: "large-session", + messages: [{ role: "user", text: "Import this large session" }], + }, + }, + ]); + }), + ); + + it.effect("reports stat, read, and parse failures as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const missingPath = path.join(codexHomePath, "missing.jsonl"); + const transcriptPaths = { + stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), + read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), + parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), + }; + const transcriptContents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session" }, + }), + ].join("\n"); + + yield* writeTranscript({ + filePath: transcriptPaths.stat, + contents: transcriptContents("stat-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.read, + contents: transcriptContents("read-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.parse, + contents: encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parse-session", cwd: workspace }, + }), + mtimeMs: nowMs, + }); + + let statCount = 0; + let readOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); + statCount += 1; + return fileSystem.stat(statCount === 1 ? filePath : missingPath); + }, + open: (filePath, options) => { + if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); + readOpenCount += 1; + return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); + }), + ); + + it.effect("does not reopen a transcript that becomes a non-file after discovery", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const nonFilePath = yield* makeTempDir("t3code-non-file-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-changed.jsonl", + ); + yield* writeTranscript({ + filePath: transcriptPath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "changed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + let transcriptStatCount = 0; + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPath) return fileSystem.stat(filePath); + transcriptStatCount += 1; + return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); + }, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptStatCount).toBe(2); + expect(transcriptOpenCount).toBe(1); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not import a transcript dated after the current time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-future.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "future-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Future work" }, + }), + ].join("\n"), + mtimeMs: nowMs + 1, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([]); + }), + ); + + it.effect("skips growth during reading without exceeding the reserved bytes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("skips a transcript that shrinks after its size check", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-shrinking.jsonl", + ); + const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shrinking-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: `${contents}\n${"padding".repeat(100)}`, + mtimeMs: nowMs, + }); + yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + return fileSystem.open( + transcriptOpenCount === 1 ? transcriptPath : shrunkPath, + options, + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not read the second transcript when the consumer takes one thread", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const makeCodexTranscript = (sessionId: string, text: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: text }, + }), + ].join("\n"); + const olderPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "23", + "rollout-older.jsonl", + ); + const newerPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-newer.jsonl", + ); + yield* writeTranscript({ + filePath: olderPath, + contents: makeCodexTranscript("older-session", "Older prompt"), + mtimeMs: nowMs - 1_000, + }); + yield* writeTranscript({ + filePath: newerPath, + contents: makeCodexTranscript("newer-session", "Newer prompt"), + mtimeMs: nowMs, + }); + + const openCounts = new Map(); + const contentReads: Array = []; + const trackedPaths = new Set([olderPath, newerPath]); + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (trackedPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + if (count === 2) contentReads.push(filePath); + } + return fileSystem.open(filePath, options); + }, + }); + + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(workspace).pipe( + Stream.take(1), + Stream.runCollect, + Effect.map((items) => Array.from(items)), + ); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["newer-session"]); + expect(contentReads).toEqual([newerPath]); + expect(openCounts.get(olderPath)).toBe(1); + }), + ); + + it.effect("does not import sessions from a T3-managed worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); + yield* fileSystem.makeDirectory(workspace, { recursive: true }); + + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-managed.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "managed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: workspace, + }); + + expect(threads).toEqual([]); + }), + ); + + it.effect("uses one deterministic provider instance for a shared session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the shared session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + }), + ); + + it.effect("uses configured order when custom instances share a session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the first account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); + }), + ); + + it.effect("keeps a second account when the first has 5000 newer files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); + const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); + const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); + const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); + const oldTranscript = path.join(oldDirectory, "old.jsonl"); + const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); + + yield* writeTranscript({ + filePath: oldTranscript, + contents: encodeTranscriptRecord({ + type: "user", + cwd: oldWorkspace, + sessionId: "old-session", + message: { role: "user", content: "Old work" }, + }), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(recentDirectory, "recent.jsonl"), + contents: encodeTranscriptRecord({ + type: "user", + cwd: recentWorkspace, + sessionId: "recent-session", + message: { role: "user", content: "Recent work" }, + }), + mtimeMs: nowMs - 1_000, + }); + + const simulatedOldTranscripts = Array.from( + { length: 5_000 }, + (_, index) => `old-${index}.jsonl`, + ); + const resolveTranscript = (filePath: string) => + path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") + ? oldTranscript + : filePath; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => + directory === oldDirectory + ? Effect.succeed(simulatedOldTranscripts) + : fileSystem.readDirectory(directory, options), + stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), + open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), + }); + + const input = { + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: recentHome }, + }, + }, + }; + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.truncated).toBe(true); + return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer(input)), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["recent-session"]); + }), + ); + }); +}); + +describe("parseAgentSessionTranscript", () => { + it.each([false, true])( + "handles the exact record limit and an interior blank overflow=%s", + (overflow) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: makeRecordLimitTranscript("/project", overflow), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + if (overflow) expect(thread).toBeNull(); + else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); + }, + ); + + it("keeps Claude text and titles while dropping malformed and tool records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + "not valid json", + JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isMeta: true, + message: { role: "user", content: "Injected skill instructions" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isCompactSummary: true, + message: { role: "user", content: "Injected compaction summary" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "Updated the login flow" }], + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "", + content: [{ type: "text", text: "The provider request failed" }], + }, + }), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toMatchObject({ + providerSessionId: "claude-session", + title: "Fix authentication", + model: "claude-sonnet-5", + messages: [ + { role: "user", text: "Fix authentication" }, + { role: "assistant", text: "Updated the login flow" }, + { role: "assistant", text: "The provider request failed" }, + ], + }); + }); + + it("drops injected Codex instructions while keeping the visible user event", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\nInternal setup instructions\n", + }, + ], + }, + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "Fix the actual bug" }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Fix the actual bug" }], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Fixed" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Fix the actual bug", + "Fixed", + ]); + }); + + it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...laterAssistantMessages, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + }); + + it("restores the canonical first prompt when a later user message remains", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const assistantMessages = Array.from({ length: 198 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...assistantMessages, + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T11:58:30.000Z", + payload: { type: "user_message", message: "Keep this later prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T11:59:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Keep this latest response" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + expect( + thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), + ).toHaveLength(1); + expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( + true, + ); + expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); + }); + + it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, + content: [{ type: "input_text", text: "Keep this older prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Keep this newer prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Ask again when needed" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this older prompt", + "Keep this newer prompt", + "Ask again when needed", + "Keep this newer prompt", + ]); + }); + + it("preserves response user text when Codex turn metadata is ambiguous", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: ["unexpected"], + content: [{ type: "input_text", text: "Keep this legacy prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: " " }, + content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this legacy prompt", + "Keep this prompt with a blank turn ID", + ]); + }); + + it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "fork-session", forked_from_id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Continue in the fork" }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("fork-session"); + }); + + it("skips Codex transcripts without a resumable session ID", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "This transcript has no session metadata" }, + }), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("uses the canonical Codex event when its turn has generated response context", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\n/tmp/project\nzsh\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: "Do something here so it looks like a real project.", + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "Do something here so it looks like a real project.", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Created the project." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Do something here so it looks like a real project."); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Do something here so it looks like a real project.", + "Created the project.", + ]); + }); + + it("preserves context markup in response-only Codex messages", () => { + const context = "\n/tmp/project\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: context, + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Initialize Git and add a README." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([ + context, + "Initialize Git and add a README.", + ]); + }); + + it("preserves a canonical Codex event that starts with context markup", () => { + const prompt = + "\n/tmp/project\n\n\nCreate a useful project."; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("preserves a Codex request heading in a canonical event", () => { + const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("## My request for Codex:"); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("keeps context markup quoted inside visible Codex user text", () => { + const quoted = + "Do not remove this example:\n\n/tmp/example\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: quoted }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); + }); + + it("skips sessions without a visible user message", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "Done" }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "claude-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("keeps the first prompt when later assistant output exceeds the message limit", () => { + const transcript = [ + encodeTranscriptRecord({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: "Keep this prompt" }, + }), + ...Array.from({ length: 250 }, (_, index) => + encodeTranscriptRecord({ + type: "assistant", + message: { role: "assistant", content: `Assistant update ${index}` }, + }), + ), + ].join("\n"); + + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: transcript, + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]?.text).toBe("Keep this prompt"); + expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000..e1e6714e2 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,1502 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type AgentSessionImportSource, + type AgentSessionProjectCandidate, + type AgentSessionProjectGit, + type AgentSessionScanResult, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseOriginUrlFromGitConfig, +} from "@t3tools/shared/git"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + createTranscriptJsonReader, + createTranscriptJsonSelector, + TranscriptJsonLimitError, +} from "./AgentSessionJson.ts"; + +/** Chunk size for full transcript reads. */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; +/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ +const METADATA_READ_BYTES = 8 * 1024; +/** Prevent malformed transcripts from turning project discovery into a full file scan. */ +const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on discovery filesystem operations per source. Newest-first + * ordering needs mtimes before the read cap can be applied, so directory reads + * and candidate stats share a larger budget. Once it runs out the scan stops. + */ +const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; +const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; +const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; +const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +/** + * Large tool results (especially screenshots) can make an otherwise ordinary + * Codex transcript several GiB. Streaming field selection avoids allocating + * those payloads. Raw I/O and selected history have separate budgets. + */ +const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; +const MAX_IMPORTED_MESSAGES = 200; +const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; +const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; +const MAX_IMPORT_TRANSCRIPTS = 100; +const MAX_IMPORT_RECORDS = 100_000; + +const TranscriptContentBlock = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), +}); + +const TranscriptMessage = Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), + model: Schema.optional(Schema.String), +}); + +const CodexTurnMetadata = Schema.Struct({ + turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), +}); + +const TranscriptRecord = Schema.Struct({ + type: Schema.optional(Schema.String), + timestamp: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + aiTitle: Schema.optional(Schema.String), + isSidechain: Schema.optional(Schema.Boolean), + isMeta: Schema.optional(Schema.Boolean), + isCompactSummary: Schema.optional(Schema.Boolean), + message: Schema.optional(TranscriptMessage), + payload: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + }), + ), +}); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); +const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); +const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); +const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); + +type DecodedTranscriptRecord = typeof TranscriptRecord.Type; + +interface AgentSessionTranscriptMetadata { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly fallbackSessionId: string; + readonly lastActiveAtMs: number; +} + +export interface AgentSessionThreadMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface AgentSessionThread { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly title: string; + readonly model: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly messages: ReadonlyArray; +} + +export type AgentSessionRecentThread = + | { + readonly _tag: "Importable"; + readonly thread: AgentSessionThread; + readonly source: AgentSessionImportSource; + } + | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Skipped" }; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + readonly recentThreads: ( + workspaceRoot: string, + completedSources?: ReadonlyArray, + ) => Stream.Stream; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; + readonly transcripts: ReadonlyArray<{ + readonly filePath: string; + readonly mtimeMs: number | null; + }>; +} + +interface TranscriptCandidate { + readonly filePath: string; + readonly mtimeMs: number; + readonly providerInstanceId: ProviderInstanceId; + readonly size: number; +} + +interface MetadataReadBudget { + bytesRemaining: number; + operationsRemaining: number; + recordsRemaining: number; + truncated: boolean; +} + +function selectMetadataTranscripts(transcripts: ReadonlyArray) { + const selected: Array = []; + let pending = Array.from( + Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), + (entries) => entries.values(), + ); + while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + const nextRound: typeof pending = []; + for (const iterator of pending) { + if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + const next = iterator.next(); + if (next.done) continue; + selected.push(next.value); + nextRound.push(iterator); + } + pending = nextRound; + } + return selected; +} + +function splitTranscriptRecords(contents: string, limit: number): string[] { + const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + return records.split("\n", limit); +} + +function extractText( + content: string | ReadonlyArray | undefined, +): string { + if (typeof content === "string") return content.trim(); + if (content === undefined) return ""; + return content + .filter( + (block) => + block.type === "text" || block.type === "input_text" || block.type === "output_text", + ) + .map((block) => block.text?.trim() ?? "") + .filter((text) => text.length > 0) + .join("\n"); +} + +function normalizeTimestamp(value: string | undefined, fallback: string): string { + if (value === undefined) return fallback; + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function codexTurnId(metadata: unknown): string | null { + const decoded = decodeCodexTurnMetadata(metadata); + if ( + Option.isNone(decoded) || + typeof decoded.value.turn_id !== "string" || + decoded.value.turn_id.trim().length === 0 + ) { + return null; + } + return decoded.value.turn_id; +} + +/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ +export function parseAgentSessionTranscript( + input: AgentSessionTranscriptMetadata & { + readonly contents: string; + }, + lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), +): AgentSessionThread | null { + if (lines.length > MAX_IMPORT_RECORDS) return null; + const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line))); + return parseAgentSessionRecords(input, records); +} + +function parseAgentSessionRecords( + input: AgentSessionTranscriptMetadata, + records: ReadonlyArray, +): AgentSessionThread | null { + const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); + // Claude filenames are session IDs. Codex rollout filenames include extra + // timestamp text, so only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + let title: string | null = null; + let model: string | null = null; + let hasCodexSessionId = false; + const messages: Array = []; + let firstUserMessage: + | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) + | undefined; + // A Codex response item can include generated setup text beside the real + // prompt. Suppress response-user records only when the shared turn ID and a + // verbatim event copy prove which prompt the user submitted. + const canonicalCodexResponseUserIndices = new Set(); + let canonicalUserTextsInTurn = new Set(); + let responseUsersInTurn: Array<{ + readonly index: number; + readonly turnId: string; + readonly text: string; + }> = []; + const finishCodexTurn = () => { + const canonicalTurnIds = new Set( + responseUsersInTurn.flatMap((responseUser) => + canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], + ), + ); + for (const responseUser of responseUsersInTurn) { + if (canonicalTurnIds.has(responseUser.turnId)) { + canonicalCodexResponseUserIndices.add(responseUser.index); + } + } + canonicalUserTextsInTurn = new Set(); + responseUsersInTurn = []; + }; + if (input.source === "codex") { + let recordIndex = -1; + for (const record of records) { + recordIndex += 1; + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "assistant" + ) { + finishCodexTurn(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message?.trim() ?? ""; + if (text.length > 0) canonicalUserTextsInTurn.add(text); + continue; + } + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "user" + ) { + const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); + const text = extractText(record.payload.content); + if (turnId !== null && text.length > 0) { + responseUsersInTurn.push({ index: recordIndex, turnId, text }); + } + } + } + finishCodexTurn(); + } + + const retainMessage = ( + message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + ) => { + if (firstUserMessage === undefined && message.role === "user") { + firstUserMessage = message; + } + messages.push(message); + if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); + }; + + const hasMatchingCodexEventInTurn = (text: string) => { + const comparisonText = text.trim(); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return false; + if ( + message?.role === "user" && + !message.codexResponseUser && + message.text.trim() === comparisonText + ) { + return true; + } + } + return false; + }; + + let recordIndex = -1; + for (const record of records) { + recordIndex += 1; + if (input.source === "claudeAgent") { + if ( + record.isSidechain === true || + record.isMeta === true || + record.isCompactSummary === true + ) { + continue; + } + if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); + if (record.aiTitle?.trim()) title = record.aiTitle.trim(); + const messageModel = record.message?.model?.trim(); + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + if (messageModel && messageModel !== "") model = messageModel; + if (record.type !== "user" && record.type !== "assistant") { + continue; + } + + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + + if (record.type === "session_meta") { + const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); + if (!hasCodexSessionId && sessionId) { + providerSessionId = sessionId; + hasCodexSessionId = true; + } + continue; + } + if (record.type === "turn_context" && record.payload?.model?.trim()) { + model = record.payload.model.trim(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message ?? ""; + if (text.trim().length === 0) continue; + // Codex can write the same prompt as both a response item and an event. + // Remove only the matching response copy so mixed-format logs keep every + // distinct user message. + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") break; + if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { + if (firstUserMessage === message) firstUserMessage = undefined; + messages.splice(index, 1); + break; + } + } + retainMessage({ + role: "user", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if ( + record.type !== "response_item" || + record.payload?.type !== "message" || + (record.payload.role !== "user" && record.payload.role !== "assistant") + ) { + continue; + } + + const extractedText = extractText(record.payload.content); + if (extractedText.length === 0) continue; + if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { + continue; + } + if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { + continue; + } + retainMessage({ + role: record.payload.role, + text: extractedText, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: record.payload.role === "user", + }); + } + + const visibleMessages = messages.map( + ({ codexResponseUser: _codexResponseUser, ...message }) => message, + ); + if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; + const firstUserMessageRetained = messages.includes(firstUserMessage); + const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; + const retainedMessages = firstUserMessageRetained + ? visibleMessages + : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); + + return { + source: input.source, + providerInstanceId: input.providerInstanceId, + providerSessionId, + title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), + model, + createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, + updatedAt: fallbackTimestamp, + messages: retainedMessages, + }; +} + +function extractDecodedCwd(record: DecodedTranscriptRecord): string | null { + const cwd = record.cwd?.trim() || record.payload?.cwd?.trim(); + return cwd && cwd.length > 0 ? cwd : null; +} + +function shouldRetainDecodedRecord( + source: AgentSessionSource, + record: DecodedTranscriptRecord, +): boolean { + if (extractDecodedCwd(record) !== null) return true; + if (source === "claudeAgent") { + return ( + record.type === "user" || + record.type === "assistant" || + record.sessionId !== undefined || + record.aiTitle !== undefined || + record.message?.model !== undefined + ); + } + return ( + record.type === "session_meta" || + record.type === "turn_context" || + (record.type === "event_msg" && record.payload?.type === "user_message") || + (record.type === "response_item" && + record.payload?.type === "message" && + (record.payload.role === "user" || record.payload.role === "assistant")) + ); +} + +/** + * Pylon runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.pylon-code/worktrees` layout + * (including channel homes such as `.pylon-code-nightly`) and T3 Code's + * `.t3/worktrees`, which also catches sandboxes from other homes on the same + * machine. Separators are normalized (and, on Windows, case folded) so the + * prefix match holds there too. Callers check both the recorded spelling and + * its realpath so a symlink into the worktrees directory cannot bypass the + * filter. + */ +const RUNTIME_HOME_WORKTREES_PATTERN = /\/\.pylon-code(?:-[a-z0-9]+)?\/worktrees\//; + +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") || + RUNTIME_HOME_WORKTREES_PATTERN.test(normalized) + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { + return { + filePath, + size: Number(stats.size), + mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), + device: stats.dev, + inode: Option.getOrNull(stats.ino), + birthtimeMs: Option.match(stats.birthtime, { + onNone: () => null, + onSome: (date) => date.getTime(), + }), + }; +} + +function sameTranscriptIdentity( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.filePath === right.filePath && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.device === right.device && + left.inode === right.inode && + left.birthtimeMs === right.birthtimeMs + ); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + // Different project imports can arrive concurrently from multiple clients. + // Only one transcript may hold its selected-history budget at a time. + const importReadLock = yield* Semaphore.make(1); + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const baseDir = path.resolve(serverConfig.baseDir); + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + const homeDir = NodeOS.homedir(); + // `/private/tmp` is what macOS reports for sessions started in `/tmp`. + const excludedProjectRoots = new Set( + [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => + normalizeProjectPathForComparison(path.resolve(directory)), + ), + ); + // Codex creates one scratch directory per conversation under + // ~/Documents/Codex//. Neither those nor anything a user + // unpacked into Downloads is a project. + const excludedProjectAncestors = [ + path.join(homeDir, "Downloads"), + path.join(homeDir, "Documents", "Codex"), + ]; + + const isExcludedProjectPath = (candidatePath: string) => + excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + excludedProjectAncestors.some((ancestor) => + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(ancestor, foldWorktreeCase), + ), + ) || + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(baseDir, foldWorktreeCase), + ) || + isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** Match directory aliases without assuming the host volume is case-insensitive. */ + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( + target: string, + knownStats?: FileSystem.File.Info, + ) { + const resolved = path.resolve(target); + const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); + if ( + Option.isSome(stats) && + Option.isSome(stats.value.ino) && + Number.isSafeInteger(stats.value.ino.value) && + stats.value.ino.value > 0 + ) { + return `inode:${stats.value.dev}:${stats.value.ino.value}`; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + return `path:${normalizeProjectPathForComparison(realPath)}`; + }); + + /** + * Git identity of a directory, or the reason it has none. Reads `.git` + * directly instead of spawning git so a scan over hundreds of candidates + * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a + * `worktrees/` directory the checkout is a linked worktree, which + * onboarding skips because its history belongs to the main checkout. + * Submodules use the same pointer shape but live under `modules/`, and + * are offered like any other repository. + */ + const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( + directory: string, + ): Effect.fn.Return< + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } + | { readonly _tag: "Worktree" } + | { readonly _tag: "NotGit" } + > { + const gitPath = path.join(directory, ".git"); + const gitStats = yield* statOption(gitPath); + if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; + let gitDir = gitPath; + if (gitStats.value.type !== "Directory") { + const pointer = yield* fileSystem + .readFileString(gitPath) + .pipe(Effect.orElseSucceed(() => "")); + const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; + gitDir = path.resolve(directory, target); + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const; + } + const configText = yield* fileSystem + .readFileString(path.join(gitDir, "config")) + .pipe(Effect.orElseSucceed(() => "")); + const originUrl = parseOriginUrlFromGitConfig(configText); + return { + _tag: "Repository", + git: { + remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), + repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + }, + } as const; + }); + + // A large history snapshot can precede session metadata. Read bounded + // chunks until a complete record names its cwd or the safety budget ends. + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + transcript: TranscriptCandidate, + budget: MetadataReadBudget, + ) { + if (transcript.size === 0) return null; + if ( + budget.bytesRemaining === 0 || + budget.operationsRemaining < 2 || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return null; + } + budget.operationsRemaining -= 1; + return yield* Effect.scoped( + fileSystem.open(transcript.filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let remaining = ""; + let bytesRead = 0; + let recordsRead = 0; + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const reserveRecord = () => { + if ( + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return false; + } + recordsRead += 1; + budget.recordsRemaining -= 1; + return true; + }; + const readLastRecord = () => { + const record = remaining + decoder.decode(); + return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + }; + + while (bytesRead < maxBytes) { + if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { + budget.truncated = true; + return null; + } + const readSize = Math.min( + METADATA_READ_BYTES, + maxBytes - bytesRead, + budget.bytesRemaining, + ); + budget.operationsRemaining -= 1; + budget.bytesRemaining -= readSize; + const next = yield* file.readAlloc(readSize); + if (Option.isNone(next)) { + return readLastRecord(); + } + + bytesRead += next.value.byteLength; + remaining += decoder.decode(next.value, { stream: true }); + const lines = remaining.split("\n"); + remaining = lines.pop() ?? ""; + + for (const line of lines) { + if (!reserveRecord()) return null; + const cwd = extractCwd(line.trim()); + if (cwd !== null) return cwd; + } + } + + if (bytesRead < transcript.size) { + budget.truncated = true; + return null; + } + return readLastRecord(); + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** + * Project history fields while reading, before allocating whole JSON records. + * Check the file identity on both sides of the read. A selected-history budget + * failure rejects the entire transcript before any imported messages persist. + */ + const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( + filePath: string, + expected: ReturnType, + recordLimit: number, + source: AgentSessionSource, + ) { + if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + + return yield* Effect.scoped( + fileSystem.open(filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { + return null; + } + const records: Array = []; + let historyBytes = 0; + let recordBytes = 0; + let recordCount = 0; + let bytesRead = 0; + const reserve = (bytes: number) => { + recordBytes += bytes; + if (historyBytes + recordBytes > MAX_IMPORT_HISTORY_BYTES) { + throw new TranscriptJsonLimitError( + "Transcript selected history exceeds the 32 MiB memory budget", + ); + } + }; + let reader = createTranscriptJsonReader(reserve, selectTranscriptPath); + let decoder = new TextDecoder(); + let recordStarted = false; + + const finishRecord = () => { + reader.write(decoder.decode()); + recordCount += 1; + if (recordCount > recordLimit) return false; + const decoded = decodeTranscriptValue(reader.finish()); + if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) { + records.push(decoded.value); + historyBytes += recordBytes; + } + recordBytes = 0; + reader = createTranscriptJsonReader(reserve, selectTranscriptPath); + decoder = new TextDecoder(); + recordStarted = false; + return true; + }; + + while (bytesRead < expected.size) { + const next = yield* file.readAlloc( + Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + ); + if (Option.isNone(next)) { + return null; + } + + bytesRead += next.value.byteLength; + const withinBudget = yield* Effect.try(() => { + let start = 0; + while (start < next.value.byteLength) { + const newline = next.value.indexOf(10, start); + const end = newline === -1 ? next.value.byteLength : newline; + recordStarted = true; + reader.write(decoder.decode(next.value.subarray(start, end), { stream: true })); + if (newline === -1) break; + if (!finishRecord()) return false; + start = newline + 1; + } + return true; + }); + if (!withinBudget) return null; + } + + if (recordStarted && !(yield* Effect.try(finishRecord))) return null; + return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) + ? { records, recordCount } + : null; + }), + ), + ), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not read imported transcript", { filePath, cause }).pipe( + Effect.as(null), + ), + ), + ); + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = environmentHome?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const projectsDir = path.join(homePath, "projects"); + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const projectDirectories = yield* readDirectory(projectsDir); + const transcripts: Array = []; + + for (const projectDirectory of projectDirectories) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(projectsDir, projectDirectory); + const directoryTranscripts = (yield* readDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + + for (const filePath of directoryTranscripts) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isNone(stats) || + stats.value.type !== "File" || + Option.isNone(stats.value.mtime) + ) { + continue; + } + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + return { transcripts, truncated }; + }, + ); + + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const sessionsDir = path.join(homePath, "sessions"); + + const transcripts: Array = []; + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + // Date-partitioned directories sort chronologically, so walking them in + // reverse spends each home's share of the operation budget on recent sessions. + for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const month of (yield* readDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const filePath = path.join(directory, entry); + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + } + } + return { transcripts, truncated }; + }, + ); + + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( + source: AgentSessionSource, + transcripts: ReadonlyArray, + budget: MetadataReadBudget, + ) { + const byOwnerAndCwd = new Map< + string, + { + cwd: string; + providerInstanceId: ProviderInstanceId; + lastActiveAtMs: number; + transcripts: Array<{ filePath: string; mtimeMs: number }>; + } + >(); + + for (const transcript of transcripts) { + const cwd = yield* readCwd(transcript, budget); + if (cwd === null) continue; + const key = `${transcript.providerInstanceId}\0${cwd}`; + const existing = byOwnerAndCwd.get(key); + if (existing) { + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); + existing.transcripts.push(transcript); + } else { + byOwnerAndCwd.set(key, { + cwd, + providerInstanceId: transcript.providerInstanceId, + lastActiveAtMs: transcript.mtimeMs, + transcripts: [transcript], + }); + } + } + + return Array.from( + byOwnerAndCwd.values(), + (group): RawCandidate => ({ + cwd: group.cwd, + source, + providerInstanceId: group.providerInstanceId, + threadCount: group.transcripts.length, + lastActiveAtMs: group.lastActiveAtMs, + transcripts: group.transcripts, + }), + ); + }); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw: Array = []; + let truncated = false; + + for (const source of ["claudeAgent", "codex"] as const) { + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter( + ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), + ) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + if (resolveProviderInstanceEnabled(legacyInstance.config)) { + instances.push(legacyInstance); + } + } + + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; + const seenHomes = new Set(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; + } + + const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; + if (seenHomes.has(homeKey)) continue; + seenHomes.add(homeKey); + homes.push({ homePath, providerInstanceId: instanceId }); + } + + const transcriptCandidates: Array = []; + const baseOperationBudget = Math.floor( + MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + ); + const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + for (const [index, home] of homes.entries()) { + const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); + if (operationBudget === 0) { + truncated = true; + continue; + } + const discovered = yield* source === "claudeAgent" + ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + truncated ||= discovered.truncated; + transcriptCandidates.push(...discovered.transcripts); + } + + transcriptCandidates.sort( + (left, right) => + right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), + ); + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + truncated = true; + } + // Give each account a turn before taking another file from the same home. + const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const metadataBudget: MetadataReadBudget = { + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + truncated: false, + }; + raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); + truncated ||= metadataBudget.truncated; + } + + return { candidates: raw, truncated }; + }); + + let cachedCandidates: ReadonlyArray | null = null; + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const { candidates: raw, truncated } = yield* collectCandidates(); + cachedCandidates = raw; + + // Filesystem identity merges symlinks and case aliases without collapsing + // distinct case-sensitive directories. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + git: AgentSessionProjectGit | null; + } + >(); + const directoryKeys = new Map(); + const gitIdentities = new Map(); + + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (isExcludedProjectPath(resolved)) continue; + let key = directoryKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directoryKeys.set(resolved, ""); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isExcludedProjectPath(realPath)) { + key = ""; + } else { + const gitIdentity = yield* readGitIdentity(resolved); + if (gitIdentity._tag === "Worktree") { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); + } + } + directoryKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + git: gitIdentities.get(key) ?? null, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + // Resolve persisted roots too. A project and a transcript can name + // different symlinks to the same directory. + const shellSnapshot = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedProjectsByRoot = new Map(); + for (const project of shellSnapshot.projects) { + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); + importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Keep the path key for missing roots and use filesystem identity for + // aliases that resolve to the same directory. + const importedProject = + importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? + importedProjectsByRoot.get(key); + const candidatePath = importedProject?.workspaceRoot ?? entry.path; + candidates.push({ + path: candidatePath, + title: path.basename(candidatePath) || candidatePath, + ...(importedProject === undefined ? {} : { projectId: importedProject.id }), + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported: importedProject !== undefined, + git: entry.git, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + ...(truncated ? { truncated: true } : {}), + }; + }); + + const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( + workspaceRoot: string, + completedSources: ReadonlyArray, + ) { + const root = path.resolve(expandHomePath(workspaceRoot)); + const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const rootIdentity = yield* directoryIdentity(root); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; + + const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + cachedCandidates = candidates; + + const eligibleTranscripts: Array<{ + readonly candidate: RawCandidate; + readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; + }> = []; + for (const candidate of candidates) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + transcript.mtimeMs < cutoffMs || + transcript.mtimeMs > nowMs + ) { + continue; + } + eligibleTranscripts.push({ + candidate, + transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, + }); + } + } + + eligibleTranscripts.sort((left, right) => { + if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { + return right.transcript.mtimeMs - left.transcript.mtimeMs; + } + return left.transcript.filePath.localeCompare(right.transcript.filePath); + }); + + const completedByFile = Map.groupBy( + completedSources, + (source) => `${source.providerInstanceId}\0${source.filePath}`, + ); + const importedSessions = new Set(); + let bytesRemaining = MAX_IMPORT_BYTES; + let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; + let recordsRemaining = MAX_IMPORT_RECORDS; + return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( + Stream.mapEffect(({ candidate, transcript }) => + Effect.gen(function* () { + const completed = completedByFile.get( + `${candidate.providerInstanceId}\0${transcript.filePath}`, + ); + if ( + completed === undefined && + (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) + ) { + return Option.some({ _tag: "Skipped" }); + } + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") { + return Option.some({ _tag: "Skipped" }); + } + const identity = transcriptIdentity(transcript.filePath, stats.value); + const completedSource = completed?.find( + (source) => + source.provider === candidate.source && sameTranscriptIdentity(source, identity), + ); + if (completedSource !== undefined) { + const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; + if (importedSessions.has(sessionKey)) return Option.none(); + importedSessions.add(sessionKey); + return Option.some({ + _tag: "AlreadyImported", + source: completedSource, + }); + } + if ( + transcriptsRemaining === 0 || + recordsRemaining === 0 || + identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || + identity.size > bytesRemaining + ) { + return Option.some({ _tag: "Skipped" }); + } + // Reserve the whole file even if its read or parse fails. + transcriptsRemaining -= 1; + bytesRemaining -= identity.size; + const snapshot = yield* readTranscript( + transcript.filePath, + identity, + recordsRemaining, + candidate.source, + ); + if (snapshot === null) { + return Option.some({ _tag: "Skipped" }); + } + recordsRemaining -= snapshot.recordCount; + + // A stable replacement file can belong to a different project than the cached candidate. + let snapshotCwd: string | null = null; + for (const record of snapshot.records) { + snapshotCwd = extractDecodedCwd(record); + if (snapshotCwd !== null) break; + } + if (snapshotCwd === null) { + return Option.some({ _tag: "Skipped" }); + } + const expandedCwd = expandHomePath(snapshotCwd.trim()); + if ( + !path.isAbsolute(expandedCwd) || + (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity + ) { + return Option.some({ _tag: "Skipped" }); + } + + const parsedThread = parseAgentSessionRecords( + { + source: candidate.source, + providerInstanceId: candidate.providerInstanceId, + fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + lastActiveAtMs: transcript.mtimeMs, + }, + snapshot.records, + ); + if (parsedThread === null) { + return Option.some({ _tag: "Skipped" }); + } + + const source: AgentSessionImportSource = { + ...identity, + provider: parsedThread.source, + providerInstanceId: parsedThread.providerInstanceId, + providerSessionId: parsedThread.providerSessionId, + }; + const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; + if (importedSessions.has(sessionKey)) { + return Option.some({ _tag: "Duplicate", source }); + } + importedSessions.add(sessionKey); + return Option.some({ + _tag: "Importable", + thread: parsedThread, + source, + }); + }).pipe(importReadLock.withPermits(1)), + ), + Stream.map(Option.toArray), + Stream.flattenIterable, + ); + }); + + const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( + workspaceRoot, + completedSources = [], + ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + + return AgentSessionScanner.of({ scan, recentThreads }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 988f1fc9c..35430e9c2 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -42,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index d3dfac17c..266146579 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -33,6 +33,7 @@ import { retainUsageLimits } from "../providerUsageRetention.ts"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; @@ -136,7 +137,11 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, instanceId, }); - const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 10bd7ce66..deedcb1e0 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -39,6 +39,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; @@ -170,6 +171,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, + binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4d30ba28d..24445301b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -226,6 +226,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 2569dc688..87cf94043 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -521,6 +521,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 0244def64..d5e4781d5 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -30,7 +30,6 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -45,10 +44,11 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { createModelSelection } from "@t3tools/shared/model"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -56,6 +56,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; @@ -168,6 +169,80 @@ const makePrimeAgentConfig = (overrides: Partial): PrimeAgen ...overrides, }); +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.writeFileString( + claudePath, + [ + "#!/usr/bin/env node", + 'import * as NodeReadline from "node:readline";', + 'if (process.argv.includes("--version")) {', + ' process.stdout.write("claude 2.1.219\\n");', + " process.exit(0);", + "}", + "const lines = NodeReadline.createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [], agents: [], models: [],", + ' output_style: "default", available_output_styles: ["default"],', + ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -290,6 +365,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 46d8778e0..4290af1c4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -6164,6 +6164,7 @@ const getBinding = vi.fn((threadId: ThreadId) => const boundedListing = makeProviderServiceLayer({ directory: { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), removeExact: () => Effect.die("ProviderService.listSessions does not use removeExact"), getBinding, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 134424a62..f3ed6f6a8 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { it, assert } from "@effect/vitest"; -import { assertSome } from "@effect/vitest/utils"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type AgentSessionImportSource, +} from "@t3tools/contracts"; +import { assert, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +const importedSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "provider-session", + filePath: "/tmp/provider-session.jsonl", + size: 100, + mtimeMs: 1_000, + device: 1, + inode: 123, + birthtimeMs: 500, +} satisfies AgentSessionImportSource; + function makeDirectoryLayer(persistenceLayer: Layer.Layer) { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( + persistenceLayer, runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), NodeServices.layer, @@ -30,7 +47,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts and reads thread bindings", () => + it.effect("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: initialThreadId, }); const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(initialThreadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId: initialThreadId, provider: ProviderDriverKind.make("codex"), }); @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } const threadIds = yield* directory.listThreadIds(); - assert.deepEqual(threadIds, [nextThreadId]); - })); + expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId])); + }), + ); - it("persists runtime fields and merges payload updates", () => + it.effect("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "starting", resumeCursor: { @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "running", runtimePayload: { @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL activeTurnId: "turn-1", }); } - })); + }), + ); + + it.effect("keeps the existing binding when an insert conflicts", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-insert-conflict"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + + yield* directory.upsert( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "stopped", + resumeCursor: { threadId: "stale-provider-thread" }, + }, + { onConflict: "ignore" }, + ); + + const binding = yield* directory.getBinding(threadId); + expect(Option.getOrThrow(binding)).toMatchObject({ + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + }), + ); + + it.effect("records source files without replacing the current provider session", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = { ...importedSource, providerSessionId: "record-source" }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" }; + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claude-current"), + status: "running", + resumeCursor: { resume: "current-native-session" }, + runtimePayload, + }); + const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + + yield* directory.recordImportedTranscript({ threadId, source }); + const replacement = { ...source, size: 200, mtimeMs: 2_000 }; + yield* directory.recordImportedTranscript({ threadId, source: replacement }); + const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondFile }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...before, + runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] }, + }); + }), + ); + + it.effect("does not create a binding when recording an imported transcript", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:missing-source-binding"); + + yield* directory.recordImportedTranscript({ threadId, source: importedSource }); + + expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true); + }), + ); + + it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const firstSource = { ...importedSource, providerSessionId: "stale-source" }; + const threadId = ThreadId.make( + `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`, + ); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "original-native-session" }, + runtimePayload: { cwd: "/tmp/stale-source-project" }, + }); + yield* directory.recordImportedTranscript({ threadId, source: firstSource }); + const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondSource }); + + yield* repository.upsert({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + runtimePayload: { + cwd: "/tmp/stale-source-project", + importedTranscripts: [firstSource, secondSource], + }, + }); + }), + ); + + it.effect("reserves imported source records for the atomic recording method", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + for (const onConflict of ["update", "ignore"] as const) { + const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const binding = { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }; + yield* directory.upsert( + { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } }, + { onConflict }, + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + cwd: "/tmp/project", + }); + + yield* directory.recordImportedTranscript({ threadId, source }); + yield* directory.upsert({ ...binding, runtimePayload: null }); + + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + importedTranscripts: [source], + }); + } + }), + ); - it("lists persisted bindings with metadata in oldest-first order", () => + it.effect("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }); - const bindings = yield* directory.listBindings(); + const bindings = (yield* directory.listBindings()).filter( + (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId, + ); assert.deepEqual(bindings, [ { threadId: olderThreadId, provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL { threadId: newerThreadId, provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }, ]); - })); + }), + ); - it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - const threadId = ThreadId.make("thread-provider-change"); + it.effect( + "resets adapterKey to the new provider when provider changes without an explicit adapter key", + () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-provider-change"); - yield* runtimeRepository.upsert({ - threadId, - providerName: "claudeAgent", - providerInstanceId: null, - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-01-01T00:00:00.000Z", - resumeCursor: null, - runtimePayload: null, - }); + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); - yield* directory.upsert({ - provider: ProviderDriverKind.make("codex"), - threadId, - }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + }); - const runtime = yield* runtimeRepository.getByThreadId({ threadId }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerName, "codex"); - assert.equal(runtime.value.adapterKey, "codex"); - } - })); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "codex"); + assert.equal(runtime.value.adapterKey, "codex"); + } + }), + ); - it("rehydrates persisted mappings across layer restart", () => + it.effect("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(threadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId, provider: ProviderDriverKind.make("codex"), }); @@ -267,8 +448,48 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }).pipe(Effect.provide(directoryLayer)); NodeFS.rmSync(tempDir, { recursive: true, force: true }); - })); - it("skips a session binding when its private commit guard retires", () => + }), + ); + + it.effect("applies the commit guard before an insert-ignore binding write", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const binding = (threadId: ThreadId, resume: string) => ({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "stopped" as const, + resumeCursor: { threadId: resume }, + }); + + const retiredThreadId = ThreadId.make("import:codex:guard-retired"); + yield* directory.upsert(binding(retiredThreadId, "retired-session"), { + commitGuard: Effect.succeed(false), + onConflict: "ignore", + }); + assert.isTrue(Option.isNone(yield* directory.getBinding(retiredThreadId))); + + const currentThreadId = ThreadId.make("import:codex:guard-current"); + yield* directory.upsert(binding(currentThreadId, "imported-session"), { + commitGuard: Effect.succeed(true), + onConflict: "ignore", + }); + expect(Option.getOrThrow(yield* directory.getBinding(currentThreadId))).toMatchObject({ + resumeCursor: { threadId: "imported-session" }, + }); + + // A passing guard still cannot replace a binding that already exists. + yield* directory.upsert(binding(currentThreadId, "stale-session"), { + commitGuard: Effect.succeed(true), + onConflict: "ignore", + }); + expect(Option.getOrThrow(yield* directory.getBinding(currentThreadId))).toMatchObject({ + resumeCursor: { threadId: "imported-session" }, + }); + }), + ); + + it.effect("skips a session binding when its private commit guard retires", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const threadId = ThreadId.make("thread-retired-generation"); @@ -283,5 +504,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL ); assert.isTrue(Option.isNone(yield* directory.getBinding(threadId))); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 625e6857c..e0b707d97 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -134,27 +134,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { } if (options?.commitGuard !== undefined && !(yield* options.commitGuard)) return; yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged - ? binding.provider - : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) + .upsert( + { + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }, + options, + ) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }), ); @@ -211,6 +214,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = ( + input, + ) => + repository + .recordImportedTranscript(input) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), + ); + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -231,6 +243,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getProvider, getBinding, removeExact, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index eb2e10106..012faadf0 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -263,6 +263,7 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f28..7d6bbe61a 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it.effect.each([ + { value: "~/.account", tail: ".account" }, + { value: "~\\.account\\work", tail: ".account\\work" }, + ])("expands configured provider homes set to $value", ({ value, tail }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnv = { + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }; + const environment = mergeProviderInstanceEnvironment( + [ + { name: "CODEX_HOME", value, sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, + { name: "CUSTOM_VALUE", value, sensitive: false }, + ], + baseEnv, + ); + + expect(environment).toEqual({ + CODEX_HOME: path.join(NodeOS.homedir(), tail), + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CUSTOM_VALUE: value, + }); + expect(baseEnv).toEqual({ + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("leaves inherited provider homes unchanged", () => { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + + expect( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e46925360..77c0c6c2d 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,7 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { expandHomePath } from "../pathExpansion.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { - next[variable.name] = variable.value; + // Child processes do not apply shell expansion to environment values. + next[variable.name] = + variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" + ? expandHomePath(variable.value) + : variable.value; } return next; } diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index b995a1257..807b0706a 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,4 +1,5 @@ import type { + AgentSessionImportSource, ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, @@ -41,12 +42,24 @@ export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; +export interface ProviderSessionDirectoryUpsertOptions { + /** Checked inside the directory's mutation permit; `false` skips the write. */ + readonly commitGuard?: Effect.Effect | undefined; + readonly onConflict?: "update" | "ignore"; +} + export interface ProviderSessionDirectoryShape { readonly upsert: ( binding: ProviderRuntimeBinding, - options?: { readonly commitGuard?: Effect.Effect | undefined }, + options?: ProviderSessionDirectoryUpsertOptions, ) => Effect.Effect; + /** Record an imported file without changing the current provider session. */ + readonly recordImportedTranscript: (input: { + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 62b5f000f..9a5ea2e0b 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -25,6 +25,7 @@ import { parseHomebrewLatestVersion, ProviderVersionCache, resolveLatestProviderVersion, + resolvePackageManagedProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; @@ -304,6 +305,42 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ).toBeNull(); }); + // The Codex Windows installer exposes `%LOCALAPPDATA%\\Programs\\OpenAI\\Codex\\bin` + // as a junction into `%CODEX_HOME%\\packages\\standalone\\current\\bin`. Node's + // realpath follows junctions, so the real path carries the standalone marker + // even though the visible path does not. + it.effect("recognizes a Windows standalone install through its junctioned bin dir", () => + Effect.gen(function* () { + const visiblePath = + "C:\\Users\\Theo\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"; + const realPath = + "C:\\Users\\Theo\\.codex\\packages\\standalone\\releases\\0.120.0-x86_64\\bin\\codex.exe"; + const capabilities = yield* resolvePackageManagedProviderMaintenance( + { + provider: driver("codex"), + npmPackageName: "@openai/codex", + nativeUpdate: { + args: ["update"], + isCommandPath: isNativeTestCommandPath("/packages/standalone/"), + }, + }, + { + binaryPath: "codex", + resolvedCommandPath: visiblePath, + realCommandPath: realPath, + env: {}, + platform: "win32", + }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(capabilities.update).toMatchObject({ + executable: visiblePath, + args: ["update"], + lockKey: "codex-native", + }); + }), + ); + it.effect("proves Windows npm ownership from the package manifest beside the shim", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-npm-windows-capabilities"); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 4e6d1b261..fa567d75c 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,6 +57,14 @@ rl.on("line", (line) => { }); return; } + if (method === "account/read") { + write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + return; + } + if (method === "skills/list" || method === "model/list") { + write({ id, result: { data: [] } }); + return; + } if (method === "thread/start") { write({ id, result: fixture.responses.threadStart }); return; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index cf369ed0d..a3c3f884b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); }); - it("does not publish start intents, streaming content, or non-awareness activity events", () => { + it("does not publish imported, start-intent, streaming, or non-awareness events", () => { const now = "2026-05-25T00:00:00.000Z"; const base = { sequence: 1, @@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: "thread-1" as ThreadId, occurredAt: now, + metadata: {}, }; expect( @@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, } as unknown as OrchestrationEvent), ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); }); it("deduplicates awareness state updates whose only change is their event timestamp", () => { @@ -413,17 +444,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ).rejects.toBeDefined(); }); - it.effect("keeps the orchestration listener armed until relay config is installed", () => + it.effect("keeps the listener armed and skips imported thread work", () => Effect.scoped( Effect.gen(function* () { const events = yield* Queue.unbounded(); const threadShellRequested = yield* Deferred.make(); + const releaseThreadShell = yield* Deferred.make(); + const threadShellRequests: Array = []; + let fetchCallCount = 0; const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; const threadId = "thread-1" as ThreadId; + const importedThreadId = "import:codex:session-1" as ThreadId; const environmentId = "env-1" as EnvironmentId; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + fetchCallCount += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + const project = { id: projectId, title: "Pylon", @@ -486,15 +532,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, - projects: [project], - threads: [thread], + projects: [], + threads: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => - Deferred.succeed(threadShellRequested, undefined).pipe( - Effect.ignore, - Effect.as(Option.some(thread)), - ), + getThreadShellById: (requestedThreadId: ThreadId) => + Effect.gen(function* () { + threadShellRequests.push(requestedThreadId); + if (requestedThreadId !== threadId) return Option.none(); + yield* Deferred.succeed(threadShellRequested, undefined); + yield* Deferred.await(releaseThreadShell); + return Option.some(thread); + }), getProjectShellById: () => Effect.succeed(Option.some(project)), } as unknown as ProjectionSnapshotQueryShape; @@ -524,17 +573,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Effect.gen(function* () { const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; yield* relay.start(); - yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); yield* Queue.offer(events, { - type: "thread.activity-appended", + type: "thread.created", sequence: 1, + eventId: "evt-import-created", + commandId: CommandId.make("cmd-import-created"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.settled", + sequence: 2, + eventId: "evt-import-settled", + commandId: CommandId.make("cmd-import-settled"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 3, eventId: "evt-1", commandId: CommandId.make("cmd-1"), aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { @@ -545,6 +617,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } as unknown as OrchestrationEvent); yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); + expect(threadShellRequests).toEqual([threadId]); + expect(fetchCallCount).toBe(0); + yield* Deferred.succeed(releaseThreadShell, undefined); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -704,6 +779,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 0ce64c6f2..43c2d952c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { } export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { + if (event.metadata.historyImport === true) { + return false; + } switch (event.type) { case "thread.message-sent": case "thread.turn-start-requested": diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2d02ea5a..ee3a12be2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -41,6 +41,7 @@ import { UsageLimitSourceId, ServerProviderMutationBusyError, ResolvedKeybindingRule, + type ServerLifecycleStreamEvent, ThreadId, TurnId, WS_METHODS, @@ -97,6 +98,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationShellSnapshot), ); +const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -132,6 +134,7 @@ import { ProviderValidationError, } from "./provider/Errors.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -493,6 +496,9 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + providerSessionDirectory?: Partial< + ProviderSessionDirectory.ProviderSessionDirectory["Service"] + >; terminalManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; @@ -762,6 +768,13 @@ const buildAppUnderTest = (options?: { uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), ...options?.layers?.providerService, }), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ + upsert: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + ...options?.layers?.providerSessionDirectory, + }), ), ), Layer.provide( @@ -975,6 +988,7 @@ const buildAppUnderTest = (options?: { }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.succeed([]), getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), @@ -5574,6 +5588,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps agent session import project failures structured over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const projectId = ProjectId.make("missing-import-project"); + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip), + ), + ); + + assert.equal(error._tag, "AgentSessionImportProjectNotFoundError"); + if (error._tag === "AgentSessionImportProjectNotFoundError") { + assert.equal(error.projectId, projectId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns scanner skip counts over websocket rpc", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-codex-", + }); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-workspace-", + }); + const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31"); + const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl"); + yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + transcriptPath, + encodeTestJson({ + timestamp: "2026-08-31T12:00:00.000Z", + type: "session_meta", + payload: { id: "rpc-skipped-session", cwd: workspaceRoot }, + }), + ); + yield* fileSystem.utimes(transcriptPath, 0, 0); + + const projectId = ProjectId.make("agent-import-rpc-project"); + const project = { + id: projectId, + title: "Agent import RPC", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-31T12:00:00.000Z", + updatedAt: "2026-08-31T12:00:00.000Z", + } as const; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: (requestedProjectId) => + Effect.succeed( + requestedProjectId === projectId ? Option.some(project) : Option.none(), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const scan = yield* client[WS_METHODS.agentSessionsScan]({}); + assert.deepEqual( + scan.candidates.map((candidate) => candidate.path), + [workspaceRoot], + ); + return yield* client[WS_METHODS.agentSessionsImport]({ projectId }); + }), + ), + ); + + assert.deepEqual(result, { importedCount: 0, skippedCount: 1 }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads Codex thread feedback through websocket rpc", () => Effect.gen(function* () { const input = { @@ -6359,6 +6470,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const streamSubscribed = yield* Deferred.make(); + const snapshotPublished = yield* Deferred.make(); + const bootstrapProjectId = ProjectId.make("project-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-bootstrap"); + const snapshotEvent = { + version: 1 as const, + sequence: 1, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "pending" as const, + }, + }; + const gapEvent = { + version: 1 as const, + sequence: 2, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "complete" as const, + bootstrapProjectId, + bootstrapThreadId, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + }, + }; + const sentinelEvent = { + version: 1 as const, + sequence: 3, + type: "ready" as const, + payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor }, + }; + const liveStream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(pubsub); + yield* Deferred.succeed(streamSubscribed, undefined); + return Stream.fromSubscription(subscription); + }), + ); + + yield* buildAppUnderTest({ + layers: { + serverLifecycleEvents: { + snapshot: PubSub.publish(pubsub, gapEvent).pipe( + Effect.andThen(Deferred.succeed(snapshotPublished, undefined)), + Effect.as({ sequence: 1, events: [snapshotEvent] }), + ), + stream: liveStream, + }, + }, + }); + + yield* Effect.gen(function* () { + yield* Deferred.await(snapshotPublished); + yield* Deferred.await(streamSubscribed); + yield* PubSub.publish(pubsub, sentinelEvent); + }).pipe(Effect.forkScoped); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "welcome"); + assert.equal(first?.sequence, 1); + if (first?.type !== "welcome") { + assert.fail("expected the pending bootstrap event"); + } + assert.equal(first.payload.bootstrapStatus, "pending"); + assert.equal(second?.type, "welcome"); + assert.equal(second?.sequence, 2); + if (second?.type !== "welcome") { + assert.fail("expected the bootstrap completion event"); + } + assert.equal(second.payload.bootstrapStatus, "complete"); + assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId); + assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId); + assert.equal(second.payload.bootstrapProjectCreated, true); + assert.equal(second.payload.bootstrapThreadCreated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 6f592e0a3..4f88c5ec5 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -186,6 +186,7 @@ it.effect("marks active running sessions that have persisted resume state", () = ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -315,6 +316,7 @@ it.effect.each( ), ), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -448,6 +450,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { }, upsert: () => Effect.void, removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -504,6 +507,7 @@ it.effect("retries continuation preparation before settling a persistent failure ), upsert: () => Effect.void, removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -576,6 +580,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), removeExact: () => Effect.succeed(false), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -656,6 +661,7 @@ it.effect( ), upsert: () => Effect.fail(writeFailure), removeExact: () => Effect.succeed(false), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -694,6 +700,7 @@ it.effect("retries failed projections and continues after a persistent failure", getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, removeExact: () => Effect.succeed(false), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -752,6 +759,7 @@ it.effect("runs restart adoption before taking the orphan inventory", () => { getBinding: () => Effect.die("recovered thread must not be orphaned"), upsert: () => Effect.die("recovered thread must not be orphaned"), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -790,6 +798,7 @@ it.effect("does not fail startup when the live provider session inventory cannot getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -861,6 +870,7 @@ for (const scenario of [ upserts.push(binding); }), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -935,6 +945,7 @@ for (const preparedStatus of [ yield* Deferred.succeed(cleared, undefined); }), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => @@ -1041,6 +1052,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( binding = next; }), removeExact: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 3266ba452..fc66b60ad 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -156,6 +156,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -183,6 +184,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa assert.deepStrictEqual(targets, { bootstrapProjectId, bootstrapThreadId, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, }); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }); @@ -215,6 +218,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -241,6 +245,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), @@ -254,6 +260,62 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }), ); +it.effect( + "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", + () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), + getPendingRequestActivities: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused thread replay stats"), + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadId, undefined); + assert.equal(targets.bootstrapThreadCreated, undefined); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]); + }), +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -283,6 +345,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -315,3 +378,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.fail("bootstrap failed"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles unexpected defects", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.die("bootstrap defect"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({})); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cf0c914dd..74f5d15c1 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -196,6 +196,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { @@ -218,45 +220,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { workspaceRoot: serverConfig.cwd, createdAt, }); + bootstrapProjectId = nextProjectId; + bootstrapProjectCreated = true; } else { nextProjectId = existingProject.value.id; + bootstrapProjectId = nextProjectId; nextThreadModelSelection = existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); } - const existingThreadId = - yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); - if (Option.isNone(existingThreadId)) { - const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(yield* randomUUID); - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* randomUUID), - threadId: createdThreadId, - projectId: nextProjectId, - title: "New thread", - modelSelection: nextThreadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - }); - bootstrapProjectId = nextProjectId; - bootstrapThreadId = createdThreadId; - } else { - bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThreadId.value; - } + yield* Effect.gen(function* () { + const existingThreadId = + yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); + if (Option.isNone(existingThreadId)) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdThreadId = ThreadId.make(yield* randomUUID); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* randomUUID), + threadId: createdThreadId, + projectId: nextProjectId, + title: "New thread", + modelSelection: nextThreadModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + bootstrapThreadId = createdThreadId; + bootstrapThreadCreated = true; + } else { + bootstrapThreadId = existingThreadId.value; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), + ), + ); }); } return { ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), } as const; }); +export const completeAutoBootstrapWelcome = ( + bootstrap: Effect.Effect, +) => + bootstrap.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( + Effect.as({ bootstrapStatus: "complete" as const }), + ), + onSuccess: (targets) => + Effect.succeed({ + ...targets, + bootstrapStatus: "complete" as const, + }), + }), + ); + const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -881,36 +917,31 @@ export const make = (options?: StartupOptions) => runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + const bootstrapCompletion = yield* completeAutoBootstrapWelcome( + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + ); + + yield* Effect.logDebug( + "startup phase: publishing completed bootstrap welcome event", + { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + ...bootstrapCompletion, + }, ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; - } - - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); yield* lifecycleEvents.publish({ version: 1, type: "welcome", payload: { environment, ...welcomeBase, - ...bootstrapTargets, + ...bootstrapCompletion, }, }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), + }).pipe(Effect.ignoreCause({ log: true })), ), ); } @@ -956,7 +987,11 @@ export const make = (options?: StartupOptions) => lifecycleEvents.publish({ version: 1, type: "welcome", - payload: { environment, ...welcomeBase }, + payload: { + environment, + ...welcomeBase, + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", + }, }), ); yield* options?.activate ?? Effect.void; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index cfc0c76af..6139988b7 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -16,6 +16,7 @@ import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -24,6 +25,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -1493,4 +1495,44 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("materializes provider secrets for terminal environment resolution", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex_terminal"); + + const initial = yield* serverSettings.getSettings; + yield* serverSettings.mutateProviderInstances({ + mutationId: ServerProviderInstancesMutationId.make("terminal-environment-secret"), + expectedProviderInstances: initial.providerInstances, + patch: { + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, + ], + config: { homePath: "~/.codex-terminal" }, + }, + }, + }, + }); + + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: undefined, + }); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + + assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); + assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); + assert.notInclude(persisted, "sk-terminal-secret"); + assert.include(persisted, '"valueRedacted": true'); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 4845f8eab..26e81f187 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,12 +7,18 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, + ProviderDriverKind, + ProviderInstanceId, + ServerProviderInstancesMutationId, + ServerSettingsError, + TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Clock from "effect/Clock"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -30,7 +36,11 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -220,6 +230,9 @@ interface CreateManagerOptions { maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; } interface ManagerFixture { @@ -265,6 +278,9 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1817,6 +1833,26 @@ it.layer( }), ); + it.effect("expands provider home paths passed to setup terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5); + + yield* manager.open({ + ...openInput(), + env: { + CODEX_HOME: "~/.codex-work", + CLAUDE_CONFIG_DIR: "~/.claude-work", + CUSTOM_ACCOUNT: "~/leave-this-value-alone", + }, + }); + + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); + expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); + expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1903,6 +1939,392 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + mutateProviderInstances: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("restarts with current provider secrets and clears bounded history", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_restart"); + const { manager, ptyAdapter, logsDir } = yield* createManager(2, { + historyByteLimit: 8, + resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + }); + const homePath = path.join(logsDir, "codex"); + // Pylon replaces provider instances only through their compare-and-set mutation. + const updateSecret = (value: string) => + Effect.gen(function* () { + const current = yield* serverSettings.getSettings; + yield* serverSettings.mutateProviderInstances({ + mutationId: ServerProviderInstancesMutationId.make(`terminal-restart-${value}`), + expectedProviderInstances: + ServerSettings.redactServerSettingsForClient(current).providerInstances, + patch: { + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath }, + environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], + }, + }, + }, + }); + }); + const input = { + providerInstanceId, + env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, + }; + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* updateSecret("first-secret"); + yield* manager.restart(restartInput(input)); + const firstProcess = ptyAdapter.processes[0]!; + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); + firstProcess.emitData("discarded\nold-one\nold-two\n"); + yield* Deferred.await(outputProcessed); + expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); + + yield* updateSecret("second-secret"); + const restarted = yield* manager.restart(restartInput(input)); + + expect(firstProcess.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + PROVIDER_SECRET: "second-secret", + CODEX_HOME: homePath, + CLIENT_FLAG: "1", + }); + expect(restarted.history).toBe(""); + expect(restarted.status).toBe("running"); + expect(restarted).not.toHaveProperty("env"); + expect(restarted).not.toHaveProperty("providerInstanceId"); + const logPath = yield* historyLogPath(logsDir); + expect(yield* readFileString(logPath)).toBe(""); + + ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(logPath)).toBe("new-two\n"); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), + ), + ), + ), + ), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4da5beedb..f67f8b6b8 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -15,6 +15,8 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -32,6 +34,9 @@ import { type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -53,11 +58,17 @@ import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; @@ -71,6 +82,8 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -89,6 +102,8 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -1273,7 +1288,8 @@ function createTerminalSpawnEnv( } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + spawnEnv[key] = + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; } } // Both PTY backends feed truecolor-capable terminal clients. @@ -1313,19 +1329,80 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, processTable: nativeTelemetry.processTable, registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1348,6 +1425,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); // One process-table snapshot per poll tick, shared across every terminal. // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and // can exhaust the PID space on hosts with many sessions (#6332). @@ -2534,7 +2629,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2551,11 +2649,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2563,11 +2662,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2819,84 +2919,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const restartResolved = (input: TerminalRestartInput) => + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), ); const close: TerminalManager["Service"]["close"] = (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a0e6df74a..eef9162c1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -62,6 +62,7 @@ import { type RelayClientInstallProgressEvent, ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -110,6 +111,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as PrimeManagedMaintenance from "./provider/prime/PrimeManagedMaintenance.ts"; import { toProviderMessageSessionAgentError } from "./provider/providerMessageSessionAgentRpcError.ts"; @@ -149,6 +151,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { RollbackSagaRunner } from "./rollback/RollbackSagaRunner.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; +import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -523,6 +527,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const rollbackSagaRunner = yield* RollbackSagaRunner; const rollbackSagaRepository = yield* RollbackSagaRepository; const sideQuestionOwnership = makeSessionSideQuestionOwnership(); @@ -575,6 +580,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2657,6 +2663,31 @@ const makeWsRpcLayer = ( deletePendingAttachment(input.attachmentId), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.agentSessionsImport]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + importRecentAgentThreads(input).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -3088,11 +3119,18 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + lifecycleEvents.stream.pipe( + Stream.runForEach((event) => Queue.offer(liveBuffer, event)), + ), + { startImmediately: true }, + ); const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( (left, right) => left.sequence - right.sequence, ); - const liveEvents = lifecycleEvents.stream.pipe( + const liveEvents = Stream.fromQueue(liveBuffer).pipe( Stream.filter((event) => event.sequence > snapshot.sequence), ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); @@ -3185,6 +3223,9 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), }); const pullRequests = yield* PullRequestService.PullRequestService; + // Built once for the route so every connection shares the scanner's import + // read lock and its per-transcript memory budget. + const agentSessionScanner = yield* AgentSessionScanner.make; return HttpRouter.add( "GET", "/ws", @@ -3211,6 +3252,9 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide( + Layer.succeed(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + ), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(PrimeManagedMaintenance.layer), Layer.provide( diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729b..dfe2b51d0 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("keeps manual token submission pending until the session is authenticated", async () => { + vi.useFakeTimers(); + let authenticated = false; + let settled = false; + try { + const testApi = await installAuthApi({ + session: () => + authenticated + ? authenticatedSession(LOOPBACK_AUTH) + : unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { submitServerAuthCredential } = await import("./environments/primary"); + + const submission = submitServerAuthCredential("retry-token").finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBe(1); + expect(settled).toBe(false); + + authenticated = true; + await vi.advanceTimersByTimeAsync(100); + await expect(submission).resolves.toBeUndefined(); + expect(testApi.calls.session).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("fails manual token submission when the session is not established", async () => { + vi.useFakeTimers(); + try { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const submission = submitServerAuthCredential("retry-token"); + const failure = submission.then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("rejects a blank pairing token with a structured validation error", async () => { const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = await import("./environments/primary/auth"); diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx new file mode 100644 index 000000000..4a241befe --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -0,0 +1,200 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type ClientSettings, + type DesktopPreviewBridge, +} from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), + createTab: vi.fn(), + closeTab: vi.fn(), + registerWebview: vi.fn(), + getPreviewConfig: vi.fn(), + activeRecordings: new Set(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + createTab: mocks.createTab, + closeTab: mocks.closeTab, + registerWebview: mocks.registerWebview, + getPreviewConfig: mocks.getPreviewConfig, + }, +})); + +vi.mock("~/components/preview/usePreviewBridge", () => ({ + usePreviewBridge: () => undefined, +})); + +vi.mock("./browserRecording", () => ({ + useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, + stopBrowserRecording: async () => null, +})); + +import { + __resetClientSettingsPersistenceForTests, + ensureClientSettingsHydrated, +} from "~/hooks/useSettings"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import * as desktopTabLifetime from "./desktopTabLifetime"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +let renderer: ReactTestRenderer | undefined; + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + mocks.getClientSettings.mockReset(); + mocks.setClientSettings.mockReset().mockResolvedValue(undefined); + mocks.createTab.mockReset().mockResolvedValue(undefined); + mocks.closeTab.mockReset().mockResolvedValue(undefined); + mocks.registerWebview.mockReset().mockResolvedValue(undefined); + mocks.getPreviewConfig.mockReset().mockResolvedValue({ + partition: "persist:t3-preview-work", + webPreferences: "contextIsolation=yes", + preloadUrl: null, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 0), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("HostedBrowserWebview settings hydration", () => { + it("starts a retained background tab only after a settings read succeeds on retry", async () => { + const firstRead = deferred(); + const retryRead = deferred(); + const tabCreation = deferred(); + mocks.getClientSettings + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(retryRead.promise); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); + const createGuest = vi.fn((_attributes: unknown) => + Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), + ); + const threadRef = { + environmentId: EnvironmentId.make("host-settings-retry"), + threadId: ThreadId.make("thread-settings-retry"), + }; + const runtimeTabId = "retained-background-tab"; + useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? createGuest(element.props) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + const failure = new Error("Saved settings are unavailable"); + await act(async () => { + const hydration = ensureClientSettingsHydrated(); + firstRead.reject(failure); + await expect(hydration).rejects.toBe(failure); + }); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + let retry!: Promise; + await act(() => { + retry = ensureClientSettingsHydrated(); + }); + expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + await act(async () => { + retryRead.resolve({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", + }); + await retry; + }); + + expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); + expect(createGuest).toHaveBeenCalledOnce(); + expect(createGuest).toHaveBeenCalledWith( + expect.objectContaining({ + partition: "persist:t3-preview-work", + src: "https://example.com", + }), + ); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.25, + colorScheme: "dark", + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + }); + expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); + expect(mocks.closeTab).not.toHaveBeenCalled(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 93d24cd74..ade36bd96 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { zoomFactor, profileId, } = props; + const clientSettingsHydrated = useClientSettingsHydrated(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { + if (!clientSettingsHydrated) return; crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [runtimeTabId]); + }, [clientSettingsHydrated, runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; - if (!webview || !config || !bridge) return; + if (!clientSettingsHydrated || !webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index a85a75612..ba18e86f4 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => settings.current, useClientSettings: () => undefined, - ensureClientSettingsHydrated: () => Promise.resolve(), + ensureClientSettingsHydrated: vi.fn(async () => undefined), })); const { resolveBrowserDefaults } = await import("./browserDefaults"); @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { ); }); }); + +describe("resolveBrowserDefaults", () => { + it("rejects failed reads and uses the saved profile after a successful retry", async () => { + await withDefaultProfile("work"); + settings.current.browserDefaultZoomFactor = 1.25; + settings.current.browserDefaultAppearance = "dark"; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserDefaults()).rejects.toBe(failure); + await expect(resolveBrowserDefaults()).resolves.toMatchObject({ + viewport: { _tag: "fill" }, + zoomFactor: 1.25, + appearance: "dark", + autoShowFloatingPreview: true, + profileId: "work", + }); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index 42ea8c680..192243afd 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -79,6 +79,7 @@ function getBrowserDefaults(): BrowserDefaults { * Opening a preview is asynchronous anyway, and before hydration the snapshot * is the schema defaults rather than the user's — a tab opened in that window * would be born at the wrong viewport, zoom and appearance and never corrected. + * Read failures reject so a new tab cannot use the wrong profile or viewport. */ export async function resolveBrowserDefaults(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 94f97001c..a60362c43 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { BrowserLinkTarget } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveLinkTarget } from "./browserLinkTarget"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + +import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; + +const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); + +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => settings, +})); const click = { metaKey: false, ctrlKey: false }; @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { } }); }); + +describe("resolveBrowserLinkTargetPreference", () => { + it.each(["system", "app"] as const)( + "rejects failed reads instead of using the current %s preference", + async (preference) => { + settings.browserLinkTarget = preference; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); + await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); + }, + ); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index d03775572..7ecffb459 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { * hydration the snapshot is the schema default ("system"), so a link clicked * in the first moments after launch would ignore a persisted "app" — opening * is asynchronous anyway, so waiting costs nothing the user can see. + * Read failures reject rather than choosing a browser without the saved preference. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 737aae00c..b14b20d2d 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -1,6 +1,8 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } = vi.hoisted(() => { const events: string[] = []; @@ -229,6 +231,37 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("clears a failed settings read before retrying recording", async () => { + const tabId = "settings-read-failure-tab"; + const error = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); + + await expect(startBrowserRecording(tabId)).rejects.toBe(error); + + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + expect(animationFrameCount).toBe(0); + expect(startScreencast).not.toHaveBeenCalled(); + expect(stopScreencast).not.toHaveBeenCalled(); + expect(getUserMedia).not.toHaveBeenCalled(); + expect(FakeMediaRecorder.instances).toHaveLength(0); + + clientSettings.browserRecordingFrameRate = 60; + await startBrowserRecording(tabId); + + expect(getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { + mandatory: expect.objectContaining({ maxFrameRate: 60 }), + }, + }); + await stopBrowserRecording(tabId); + + expect(startScreencast).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + }); + it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getUserMedia.mockResolvedValueOnce({ diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index e362a358e..4364ebf06 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -419,10 +419,12 @@ export async function startBrowserRecording( activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); try { - const frameRatePromise = ensureClientSettingsHydrated().then( - () => getClientSettings().browserRecordingFrameRate, - ); - const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + await ensureClientSettingsHydrated().catch((cause: unknown) => { + clearActiveRecording(recording); + throw cause; + }); + const frameRate = getClientSettings().browserRecordingFrameRate; + await waitForBrowserRecordingPaint(); let source: DesktopPreviewRecordingSource; try { source = await bridge.recording.startScreencast(tabId); diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 80bfa0d27..c5338ecf4 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, } from "@t3tools/contracts"; @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; +import * as browserDefaults from "./browserDefaults"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; -/** Client settings are unset in tests, so creation carries the schema defaults. */ +/** Tests load default settings unless they select other preferences. */ const DEFAULT_TAB_STATE = { zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); closeTab.mockClear(); createTab.mockClear(); stopBrowserRecording.mockClear(); @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { + vi.useFakeTimers(); + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const failed = acquireDesktopTab("tab_settings_retry"); + + await expect(failed.ready).rejects.toBe(failure); + expect(createTab).not.toHaveBeenCalled(); + failed.release(); + await vi.advanceTimersByTimeAsync(0); + + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + }); + createTab.mockResolvedValueOnce(undefined); + const retry = acquireDesktopTab("tab_settings_retry"); + await retry.ready; + + expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { + zoomFactor: 1.25, + colorScheme: "dark", + }); + retry.release(); + await vi.advanceTimersByTimeAsync(0); }); it("shares tab creation readiness across concurrent leases", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index f506e42e7..a320e3ba3 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( readonly message: string; }> {} +export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return "Saved browser settings could not be loaded."; + } +} + export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; readonly input: PreviewOpenInput; @@ -47,8 +55,13 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const defaults = await resolveBrowserDefaults(); +}): Promise> { + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -82,7 +95,12 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError + > +> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/browser/useOpenLink.test.tsx b/apps/web/src/browser/useOpenLink.test.tsx index a5fcf444a..73ee27ddd 100644 --- a/apps/web/src/browser/useOpenLink.test.tsx +++ b/apps/web/src/browser/useOpenLink.test.tsx @@ -26,8 +26,12 @@ vi.mock("~/localApi", () => ({ })); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.openPreview })); vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {} } })); -vi.mock("./openFileInPreview", () => ({ openUrlInPreview: mocks.openUrl })); +vi.mock("./openFileInPreview", async (importOriginal) => ({ + ...(await importOriginal()), + openUrlInPreview: mocks.openUrl, +})); +import { BrowserSettingsReadError } from "./openFileInPreview"; import { useOpenLink } from "./useOpenLink"; const threadRef = { environmentId: "local", threadId: "thread-1" } as ScopedThreadRef; @@ -101,6 +105,18 @@ describe("useOpenLink", () => { expect(mocks.recordVisit).not.toHaveBeenCalled(); }, ); + it.each(["failure", "rejection"])( + "rejects a settings read %s without opening either browser", + async (kind) => { + const error = new BrowserSettingsReadError({ cause: new Error("storage unavailable") }); + if (kind === "failure") + mocks.openUrl.mockResolvedValue(AsyncResult.failure(Cause.fail(error))); + else mocks.openUrl.mockRejectedValue(error); + await expect(opener(threadRef)(url)).rejects.toBe(error); + expect(mocks.openExternal).not.toHaveBeenCalled(); + expect(mocks.recordVisit).not.toHaveBeenCalled(); + }, + ); it("leaves interrupted opens alone", async () => { mocks.openUrl.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); await opener(threadRef)(url); diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 66c2454fa..1f595b9cc 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,5 +1,8 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; @@ -12,7 +15,7 @@ import { resolveBrowserLinkTargetPreference, resolveLinkTarget, } from "./browserLinkTarget"; -import { openUrlInPreview } from "./openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; * * An in-app open that fails falls back to the system browser rather than * dropping the click: the user asked for the link, and the setting only says - * where it should go first. The returned promise rejects only when that - * fallback fails too, the same way `shell.openExternal` does. + * where it should go first. Failed settings reads reject without opening a + * browser. The promise also rejects if the system-browser fallback fails. */ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, @@ -53,8 +56,11 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( recordVisitForThread(targetThreadRef, url); return; } + const failure = squashAtomCommandFailure(result); + if (failure instanceof BrowserSettingsReadError) throw failure; console.error(result.cause); } catch (cause) { + if (cause instanceof BrowserSettingsReadError) throw cause; console.error(cause); } } diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index db69fe96c..95a024c9d 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -52,22 +52,106 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); - it("reports structured decode failures while preserving the fallback", async () => { + it.each(["not-json", "[]"])( + "falls back to defaults for a document that is not settings JSON: %s", + async (value) => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", value); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + "Could not read persisted client settings.", + ...(value === "not-json" + ? [ + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + }), + ] + : []), + ); + expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); + }, + ); + + // T3 Code served from the same origin writes `confirmQuit` as "hold" | "direct" | "double-click". + const t3CodeDocument = JSON.stringify({ + confirmQuit: "hold", + timestampFormat: "12-hour", + wordWrap: false, + }); + + it("keeps readable settings when another client's value cannot be decoded", async () => { const testWindow = getTestWindow(); - testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + testWindow.localStorage.setItem("t3code:client-settings:v1", t3CodeDocument); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const write = vi.spyOn(testWindow.localStorage, "setItem"); const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - expect(readBrowserClientSettings()).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "Could not read persisted client settings.", + expect(readBrowserClientSettings()).toEqual({ + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour", + wordWrap: false, + }); + expect(write).not.toHaveBeenCalled(); + }); + + it("hydrates the app and leaves an unread value in place until that setting changes", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", t3CodeDocument); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { ensureClientSettingsHydrated, getClientSettings, persistClientSettingsUpdate } = + await import("./hooks/useSettings"); + const storedDocument = () => + JSON.parse(testWindow.localStorage.getItem("t3code:client-settings:v1") ?? "null"); + + await ensureClientSettingsHydrated(); + expect(getClientSettings()).toEqual({ + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour", + wordWrap: false, + }); + + await persistClientSettingsUpdate((current) => ({ + ...current, + onboardingCompletedAt: "2026-09-10T12:00:00.000Z", + })); + expect(storedDocument()).toMatchObject({ + confirmQuit: "hold", + timestampFormat: "12-hour", + onboardingCompletedAt: "2026-09-10T12:00:00.000Z", + }); + + await persistClientSettingsUpdate((current) => ({ ...current, confirmQuit: false })); + expect(storedDocument()).toMatchObject({ confirmQuit: false }); + await persistClientSettingsUpdate((current) => ({ ...current, wordWrap: true })); + expect(storedDocument()).toMatchObject({ confirmQuit: false, wordWrap: true }); + }); + + it("preserves saved settings across a transient read failure", async () => { + const testWindow = getTestWindow(); + const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + const write = vi.spyOn(testWindow.localStorage, "setItem"); + const failure = new Error("storage unavailable"); + vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { + throw failure; + }); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(() => readBrowserClientSettings()).toThrow( expect.objectContaining({ _tag: "LocalStorageOperationError", - operation: "decode", + operation: "read", storageKey: "t3code:client-settings:v1", - cause: expect.anything(), + cause: failure, }), ); + expect(readBrowserClientSettings()).toEqual(settings); + expect(write).not.toHaveBeenCalled(); }); it("defaults word wrap on and discards obsolete wrapping preferences", async () => { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index cdbdef32b..6bb9787a3 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -1,30 +1,82 @@ -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { + decodeStoredClientSettings, + encodeStoredClientSettings, + retainUnreadClientSettings, + type ClientSettings, + type StoredClientSettings, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; -import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; +import { + getLocalStorageItem, + LocalStorageOperationError, + setLocalStorageItem, +} from "./hooks/useLocalStorage"; const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const isLocalStorageOperationError = Schema.is(LocalStorageOperationError); + +// Pylon and T3 Code share this key when they are served from the same origin, +// so the document can hold values in shapes this build cannot read. +let storedClientSettings: StoredClientSettings | null = null; function hasWindow(): boolean { return typeof window !== "undefined"; } +/** + * Reads saved client settings. Storage access failures throw so hydration can + * retry without replacing preferences it never saw. A document that parses but + * holds values this build cannot decode keeps every readable setting and falls + * back to defaults for the rest; a document that is not settings JSON at all + * falls back to defaults entirely. + */ export function readBrowserClientSettings(): ClientSettings | null { if (!hasWindow()) { return null; } + let document: unknown; try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); + document = getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, Schema.Unknown); } catch (error) { + if (!isLocalStorageOperationError(error) || error.operation !== "decode") { + throw error; + } console.error("Could not read persisted client settings.", error); + storedClientSettings = null; + return null; + } + if (document === null) { + storedClientSettings = null; return null; } + + storedClientSettings = decodeStoredClientSettings(document); + if (storedClientSettings === null) { + console.error("Could not read persisted client settings."); + return null; + } + const unreadKeys = Object.keys(storedClientSettings.unreadValues); + if (unreadKeys.length > 0) { + console.error("Some persisted client settings could not be read; using defaults for them.", { + settings: unreadKeys, + }); + } + return storedClientSettings.settings; } +/** Writes settings, leaving unread stored values in place until their setting changes. */ export function writeBrowserClientSettings(settings: ClientSettings): void { if (!hasWindow()) { return; } - setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); + const next = retainUnreadClientSettings(settings, storedClientSettings); + setLocalStorageItem( + CLIENT_SETTINGS_STORAGE_KEY, + encodeStoredClientSettings(next), + Schema.Unknown, + ); + storedClientSettings = next; } diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index d0e21b390..719c01494 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -4,7 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re const HEIGHT_TRANSITION_FALLBACK_MS = 250; -export function AnimatedHeight({ children }: { readonly children: ReactNode }) { +export function AnimatedHeight({ + children, + holdHeight = false, +}: { + readonly children: ReactNode; + /** Retain the previous content height while a replacement is loading. */ + readonly holdHeight?: boolean; +}) { const contentRef = useRef(null); const [heightState, setHeightState] = useState<{ readonly height: number | null; @@ -22,6 +29,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { }, [heightState.height, heightState.isClipping]); useLayoutEffect(() => { + if (holdHeight) return; const element = contentRef.current; if (!element) return; let firstFrameId: number | null = null; @@ -67,7 +75,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { resizeObserver.disconnect(); cancelPendingFrames(); }; - }, []); + }, [holdHeight]); return (
-
{children}
+
+ {children} +
); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e80147e95..5eba1cde5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -178,6 +178,7 @@ import { openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, + BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { useOpenLink } from "../browser/useOpenLink"; @@ -2441,6 +2442,18 @@ function useChatMarkdownState({ } return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { if (result._tag === "Success") recordVisitForThread(threadRef, url); + else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error.message, + }), + ); + } + } return result; }); }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c02b3d6ea..f506a4d41 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -7,9 +7,13 @@ import { ProviderInstanceId, ThreadId, TurnId, + type ServerProvider, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { resolveComposerInstanceSelection } from "../composerInstanceSelection"; +import { deriveProviderInstanceEntries } from "../providerInstances"; + import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { @@ -28,6 +32,7 @@ import { buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + deriveLockedProvider, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, @@ -812,6 +817,151 @@ describe("buildThreadTurnInterruptInput", () => { }); }); +describe("deriveLockedProvider for imported history", () => { + function entry(driver: string, instanceId = driver, overrides: Partial = {}) { + return deriveProviderInstanceEntries([ + { + driver: ProviderDriverKind.make(driver), + instanceId: ProviderInstanceId.make(instanceId), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + ...overrides, + }, + ])[0]!; + } + + function importedThread(instanceId: ProviderInstanceId) { + return makeThread({ + modelSelection: { instanceId, model: "default" }, + messages: [ + { + id: MessageId.make(`import:${instanceId}:session:000000`), + role: "user", + text: "Continue the imported conversation", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + }); + } + + function selectComposerInstance(input: { + readonly entries: ReadonlyArray>; + readonly draftActiveProvider: ProviderInstanceId | null; + readonly threadInstanceId: ProviderInstanceId; + readonly lockedProvider: ProviderDriverKind | null; + }) { + return resolveComposerInstanceSelection({ + entries: input.entries, + draftActiveProvider: input.draftActiveProvider, + sessionInstanceId: null, + threadInstanceId: input.threadInstanceId, + projectInstanceId: null, + lockedProvider: input.lockedProvider, + nowMs: Date.parse(now), + }); + } + + it.each([ + ["claudeAgent", "claude_work"], + ["codex", "codex_work"], + ["ollama", "local_models"], + ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => { + const importedEntry = entry(driver, instanceId); + const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry]; + const thread = importedThread(importedEntry.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: entries[0]!.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(thread.session).toBeNull(); + expect(lockedProvider).toBe(driver); + expect( + selectComposerInstance({ + entries, + draftActiveProvider: null, + threadInstanceId: thread.modelSelection.instanceId, + lockedProvider, + }).entry?.instanceId, + ).toBe(importedEntry.instanceId); + }); + + it("keeps the session driver authoritative over instance and draft selections", () => { + const selected = entry("claudeAgent", "claude_work"); + const sessionEntry = entry("ollama", "local_models"); + const thread = importedThread(selected.instanceId); + + expect( + deriveLockedProvider({ + thread: { + ...thread, + session: { + ...readySession, + providerName: sessionEntry.driverKind, + providerInstanceId: sessionEntry.instanceId, + }, + }, + selectedProvider: selected.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: [selected.snapshot, sessionEntry.snapshot], + }), + ).toBe(sessionEntry.driverKind); + }); + + it.each(["missing", "disabled"] as const)( + "does not move imported history to another driver when its instance is %s", + (state) => { + const imported = entry("claudeAgent", "claude_work", { enabled: false }); + const other = entry("codex"); + const entries = state === "missing" ? [other] : [other, imported]; + const thread = importedThread(imported.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: other.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(lockedProvider).not.toBeNull(); + expect( + selectComposerInstance({ + entries, + draftActiveProvider: other.instanceId, + threadInstanceId: imported.instanceId, + lockedProvider, + }).entry, + ).toBeUndefined(); + }, + ); + + it("leaves a new draft free to select a different driver", () => { + const original = entry("claudeAgent", "claude_work"); + const selected = entry("codex", "codex_work"); + expect( + deriveLockedProvider({ + thread: makeThread({ + modelSelection: { instanceId: original.instanceId, model: "default" }, + }), + selectedProvider: selected.instanceId, + threadProvider: original.instanceId, + providers: [original.snapshot, selected.snapshot], + }), + ).toBeNull(); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1e7bfc150..96b15e61d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -684,22 +684,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } -// `threadProvider` is the open branded driver kind carried by the session. -// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe -// rollback / fork behavior — the routing layer is the right place to surface -// "driver not installed" errors, not the lock state. -// -// `selectedProvider` takes the same open-string shape because the composer -// now tracks the picker selection as a `ProviderInstanceId` (e.g. -// `codex_personal`). Custom instance ids that don't directly match a -// registered driver resolve to `null` here, which matches the existing -// "unknown driver -> unlocked" semantics. Callers that want the lock to track -// a custom instance's underlying driver kind should resolve the instance id -// upstream and pass the correlated kind. +// Imported history has no session until its first prompt. Resolve its instance +// through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; + providers: ReadonlyArray>; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; @@ -708,14 +699,18 @@ export function deriveLockedProvider(input: { if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } + // Preserve the existing lock while an instance is missing from the catalog; + // a started thread must not silently fall back to a different driver. + const threadProvider = + input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ?? + input.threadProvider; + const selectedProvider = + input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ?? + input.selectedProvider; const narrowedThreadProvider = - input.threadProvider && isProviderDriverKind(input.threadProvider) - ? input.threadProvider - : null; + threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null; const narrowedSelectedProvider = - input.selectedProvider && isProviderDriverKind(input.selectedProvider) - ? input.selectedProvider - : null; + selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null; return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d2c4b664..5d5da624e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -187,6 +187,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "../browser/openFileInPreview"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -2411,6 +2412,12 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -2420,12 +2427,8 @@ export default function ChatView(props: ChatViewProps) { thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, + providers: providerStatuses, }); - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; // Same fallback the load-failure view offers: a pull request stays readable on @@ -2657,7 +2660,6 @@ export default function ChatView(props: ChatViewProps) { versionMismatchThreadContinuation, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const threadHandoffEntries = useMemo( () => applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), [providerStatuses, settings], @@ -4083,6 +4085,18 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, openPreview, ...(profileId === undefined ? {} : { profileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } }); }, [activeThreadRef, openPreview], diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0c306571e..edb2b75c8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -47,6 +47,7 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + ImportIcon, LinkIcon, LibraryIcon, MessageCircleQuestionIcon, @@ -1768,6 +1769,28 @@ function OpenCommandPaletteDialog(props: { }); } + // First-run setup has no other way back in once it is finished. + actionItems.push({ + kind: "action", + value: "action:welcome-setup", + searchTerms: [ + "setup", + "welcome", + "onboarding", + "import", + "projects", + "computers", + "agents", + "claude", + "codex", + ], + title: "Set up computers and import projects", + icon: , + run: async () => { + await navigate({ to: "/welcome" }); + }, + }); + actionItems.push({ kind: "action", value: "action:theme-editor", diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx new file mode 100644 index 000000000..09bd92c82 --- /dev/null +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -0,0 +1,36 @@ +import { PlusIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { openCommandPalette } from "../commandPaletteBus"; +import { Button } from "./ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { SidebarInset } from "./ui/sidebar"; + +export function NoProjectsHero() { + const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + + return ( + +
+ +
+ + + What should we work on? + + + Add a project to start your first thread. + +
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index fa72d1593..26aa667ae 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,12 +1,99 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveTerminalPathOpenTargets, shouldClearTerminalSelectionAction, shouldHandleTerminalExit, + terminalContextMenuItems, terminalSelectionLineRange, + terminalSelectionMenuItems, + terminalThemeFromApp, } from "./ThreadTerminalDrawer"; +describe("terminal selection menus", () => { + it("omits Add to chat when the terminal has no chat target", () => { + expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]); + expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ + "add-to-chat", + "copy", + "paste", + ]); + + expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ + "copy", + ]); + expect( + terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), + ).toEqual(["copy", "paste"]); + }); +}); + +describe("terminalThemeFromApp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses terminal colors inherited by the mount instead of a light document theme", () => { + const root = { classList: { contains: () => false } }; + const body = {}; + const drawer = {}; + let canvasColor = "#000"; + const colors: Record = { + "#000": [0, 0, 0, 255], + "#fff": [255, 255, 255, 255], + "#ddd": [221, 221, 221, 255], + "#111": [17, 17, 17, 255], + }; + + vi.stubGlobal("document", { + documentElement: root, + body, + querySelector: () => drawer, + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ + clearRect: () => undefined, + fillRect: () => undefined, + get fillStyle() { + return canvasColor; + }, + set fillStyle(value: string) { + canvasColor = value; + }, + getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }), + }), + }), + }); + vi.stubGlobal("getComputedStyle", (element: object) => { + const local = element === drawer; + const values = local + ? { + "--terminal-background": "#000", + "--terminal-foreground": "#fff", + "--terminal-cursor": "#ddd", + "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", + } + : { + "--terminal-background": "#fff", + "--terminal-foreground": "#111", + }; + return { + backgroundColor: local ? "#000" : "#fff", + color: local ? "#fff" : "#111", + colorScheme: local ? "dark" : "light", + getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", + }; + }); + + const theme = terminalThemeFromApp(); + + expect(theme.background).toEqual({ r: 0, g: 0, b: 0 }); + expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 }); + expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 }); + }); +}); + describe("terminal selection actions", () => { it("clears a pending or currently owned menu when the selection disappears", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cc1ed66a7..a5ab721d7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, + type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -42,6 +43,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -173,16 +175,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); + const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -191,8 +200,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -233,10 +250,14 @@ export function terminalSelectionLineRange(position: { export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; -/** Post-selection popup: just the two selection actions, always enabled. */ -function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { +/** Post-selection popup: available selection actions, always enabled. */ +export function terminalSelectionMenuItems(options?: { + canAddToChat?: boolean; +}): ContextMenuItem<"add-to-chat" | "copy">[] { return [ - { id: "add-to-chat", label: "Add to chat" }, + ...(options?.canAddToChat === false + ? [] + : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])), { id: "copy", label: "Copy" }, ]; } @@ -247,13 +268,15 @@ function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] * (and Electron's default editing menu) can only paste into an editable * element, so a canvas terminal never gets a usable entry from them. */ -function terminalContextMenuItems(options: { +export function terminalContextMenuItems(options: { hasSelection: boolean; + canAddToChat?: boolean; }): ContextMenuItem[] { + const { hasSelection, canAddToChat = true } = options; return [ - ...terminalSelectionMenuItems().map((item) => ({ + ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ ...item, - disabled: !options.hasSelection, + disabled: !hasSelection, })), { id: "paste", label: "Paste" }, ]; @@ -293,8 +316,9 @@ interface TerminalViewportProps { cwd: string; worktreePath?: string | null; runtimeEnv?: Record; + providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; visible: boolean; @@ -317,7 +341,7 @@ interface TerminalLaunchLocation { readonly runtimeEnv?: Record; } -function TerminalViewport({ +export function TerminalViewport({ advancedTypography, threadRef, threadId, @@ -326,6 +350,7 @@ function TerminalViewport({ cwd, worktreePath, runtimeEnv, + providerInstanceId, onSessionExited, onAddTerminalContext, focusRequestId, @@ -368,8 +393,9 @@ function TerminalViewport({ onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext(selection); + onAddTerminalContext?.(selection); }); + const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => resolveTerminalFontPreference({ @@ -394,6 +420,7 @@ function TerminalViewport({ cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }, }); const writeTerminal = useEffectEvent((data: string) => @@ -644,7 +671,10 @@ function TerminalViewport({ let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( - terminalContextMenuItems({ hasSelection: selectionAction !== null }), + terminalContextMenuItems({ + hasSelection: selectionAction !== null, + canAddToChat: canAddSelectionToChat(), + }), { x: event.clientX, y: event.clientY }, ); } catch (error) { @@ -657,7 +687,9 @@ function TerminalViewport({ } switch (clicked) { case "add-to-chat": - if (selectionAction) addSelectionToChat(selectionAction.selection); + if (selectionAction && canAddSelectionToChat()) { + addSelectionToChat(selectionAction.selection); + } return; case "copy": if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); @@ -684,7 +716,10 @@ function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show(terminalSelectionMenuItems(), nextAction.position) + .show( + terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), + nextAction.position, + ) .finally(() => { if (openSelectionMenuRequestIdRef.current === requestId) { openSelectionMenuRequestIdRef.current = null; @@ -695,7 +730,7 @@ function TerminalViewport({ } switch (clicked) { case "add-to-chat": - addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -777,6 +812,14 @@ function TerminalViewport({ openPreview, fallbackToBrowser, forceBrowser: event.metaKey || event.ctrlKey, + }).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); return; } diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx new file mode 100644 index 000000000..6474c2208 --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -0,0 +1,324 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; + +const discovery = vi.hoisted(() => ({ + state: null as Discovery.RelayEnvironmentDiscoveryState | null, + listeners: new Set<() => void>(), + refreshCommand: Symbol("refresh"), + registerCommand: Symbol("register"), + refresh: vi.fn<() => Promise>>(), + register: vi.fn(), + listEnvironments: vi.fn<() => Promise>(), +})); + +vi.mock("~/state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: discovery.refreshCommand }, +})); +vi.mock("~/connection/catalog", () => ({ + environmentCatalog: { register: discovery.registerCommand }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === discovery.refreshCommand ? discovery.refresh : discovery.register, +})); +vi.mock("~/state/environments", async () => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + discovery.listeners.add(listener); + return () => discovery.listeners.delete(listener); + }; + const read = () => { + if (discovery.state === null) throw new Error("Discovery fixture is not initialized"); + return discovery.state; + }; + return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) }; +}); +vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null })); +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ children }: { children: ReactNode }) => {children}, + TooltipPopup: () => null, +})); +vi.mock("../ui/checkbox", () => ({ + Checkbox: (props: { + checked: boolean; + disabled: boolean; + onCheckedChange: (checked: boolean) => void; + }) => ( + props.onCheckedChange(event.target.checked)} + /> + ), +})); +vi.mock("../ui/button", () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; + +const newMachineId = EnvironmentId.make("new-computer"); +const linkedMachines: DiscoveredEnvironments = new Map([ + [ + newMachineId, + { + environment: { + environmentId: newMachineId, + label: "Work laptop", + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test/ws", + providerKind: "manual", + }, + linkedAt: "2026-09-05T12:00:00.000Z", + }, + availability: "online", + status: Option.none(), + error: Option.none(), + }, + ], +]); + +let renderer: ReactTestRenderer | null; +let page: EventTarget & { visibilityState: DocumentVisibilityState }; +let browserWindow: EventTarget; + +function publish(state: Discovery.RelayEnvironmentDiscoveryState) { + discovery.state = state; + for (const listener of discovery.listeners) listener(); +} + +async function mount(refreshWhileEmpty = true) { + await act(async () => { + renderer = create( + Waiting for your computer to connect.

} + />, + ); + }); +} + +async function advance(milliseconds: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + page = Object.assign(new EventTarget(), { visibilityState: "visible" as const }); + browserWindow = new EventTarget(); + vi.stubGlobal("document", page); + vi.stubGlobal("window", browserWindow); + renderer = null; + discovery.listeners.clear(); + discovery.state = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), + }; + discovery.listEnvironments.mockReset().mockResolvedValue(new Map()); + discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + discovery.refresh.mockReset().mockImplementation(async () => { + publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() }); + const environments = await discovery.listEnvironments(); + publish({ environments, refreshing: false, offline: false, error: Option.none() }); + return AsyncResult.success(undefined); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("cloud onboarding discovery", () => { + it("signals that the section can expand after initial discovery settles", async () => { + let finishDiscovery!: (environments: DiscoveredEnvironments) => void; + discovery.listEnvironments.mockReturnValue( + new Promise((resolve) => { + finishDiscovery = resolve; + }), + ); + const onDiscoveryReady = vi.fn(); + await act(async () => { + renderer = create( + , + ); + }); + expect(onDiscoveryReady).not.toHaveBeenCalled(); + await act(async () => { + finishDiscovery(linkedMachines); + }); + expect(onDiscoveryReady).toHaveBeenCalledTimes(1); + expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + }); + + it("connects and selects discovered computers by default without overwriting deselection", async () => { + discovery.listEnvironments.mockResolvedValue(linkedMachines); + const autoSelectedComputers = new Set(); + function Setup() { + const [selectedIds, setSelectedIds] = useState>(new Set()); + return ( + + setSelectedIds((current) => { + const next = new Set(current); + if (checked) next.add(id); + else next.delete(id); + return next; + }), + }} + /> + ); + } + await act(async () => { + renderer = create(); + }); + + expect(discovery.register).toHaveBeenCalledTimes(1); + expect(renderer!.root.findByType("input").props.checked).toBe(true); + await act(async () => { + await renderer!.root.findByType("input").props.onChange({ target: { checked: false } }); + }); + await act(async () => { + publish({ ...discovery.state!, environments: new Map(linkedMachines) }); + }); + expect(renderer!.root.findByType("input").props.checked).toBe(false); + expect(discovery.register).toHaveBeenCalledTimes(1); + }); + + it("shows a newly linked computer without remounting and stops polling once found", async () => { + discovery.listEnvironments + .mockResolvedValueOnce(new Map()) + .mockResolvedValueOnce(linkedMachines); + await mount(); + expect(renderer!.root.findByType("p").children).toEqual([ + "Waiting for your computer to connect.", + ]); + + await advance(5_000); + + expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ + "Work laptop", + ]); + expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("keeps a discovered computer visible when it is added to the browser", async () => { + discovery.listEnvironments.mockResolvedValue(linkedMachines); + await mount(); + expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + await act(async () => { + renderer!.update( + , + ); + }); + expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ + "Work laptop", + ]); + expect(renderer!.root.findByType("button").children).toEqual(["Connected"]); + }); + + it("waits while hidden and refreshes immediately when visible again", async () => { + page.visibilityState = "hidden"; + await mount(); + await advance(30_000); + expect(discovery.listEnvironments).not.toHaveBeenCalled(); + + await act(async () => { + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + }); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + browserWindow.dispatchEvent(new Event("focus")); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + }); + + it("does not overlap a slow refresh or restart polling after unmount", async () => { + let resolveRefresh!: (environments: DiscoveredEnvironments) => void; + const pending = new Promise((resolve) => { + resolveRefresh = resolve; + }); + discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending); + await mount(); + await advance(5_000); + expect(renderer!.root.findByType("p").children).toEqual([ + "Waiting for your computer to connect.", + ]); + + browserWindow.dispatchEvent(new Event("focus")); + page.dispatchEvent(new Event("visibilitychange")); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + + await act(async () => renderer!.unmount()); + renderer = null; + await act(async () => resolveRefresh(new Map())); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("pauses while offline and resumes when discovery is online", async () => { + await mount(); + await act(async () => publish({ ...discovery.state!, offline: true })); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + + await act(async () => publish({ ...discovery.state!, offline: false })); + await advance(5_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("does not add polling to other cloud lists", async () => { + await mount(false); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 63bb48090..299aa4d44 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -11,7 +11,7 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; @@ -20,11 +20,15 @@ import { useRelayEnvironmentDiscovery } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; +import { Checkbox } from "../ui/checkbox"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; +const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000; + export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; @@ -55,12 +59,22 @@ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, savedEnvironments, showSavedEnvironments = false, + refreshWhileEmpty = false, empty = null, + selection, + onDiscoveryReady, }: { readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironments: ReadonlyArray; readonly showSavedEnvironments?: boolean; + readonly refreshWhileEmpty?: boolean; readonly empty?: ReactNode; + readonly onDiscoveryReady?: () => void; + readonly selection?: { + readonly autoSelectedComputers?: Set; + readonly selectedIds: ReadonlySet; + readonly onChange: (environmentId: EnvironmentId, selected: boolean) => void; + }; }) { const environmentsState = useRelayEnvironmentDiscovery(); const registerEnvironment = useAtomCommand(environmentCatalog.register, { @@ -69,6 +83,10 @@ export function CloudEnvironmentConnectRows({ const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); + const refreshDiscoveryWhenIdle = useEffectEvent(async () => { + if (environmentsState.refreshing || environmentsState.offline) return; + await refreshRelayEnvironments(); + }); const connectRelayEnvironment = useCallback( (environment: RelayClientEnvironmentRecord) => registerEnvironment( @@ -81,31 +99,43 @@ export function CloudEnvironmentConnectRows({ ), [registerEnvironment], ); - const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( - null, - ); + const [connectingEnvironmentIds, setConnectingEnvironmentIds] = useState< + ReadonlySet + >(new Set()); const savedById = new Map( savedEnvironments.map((environment) => [environment.environmentId, environment]), ); useEffect(() => { - void refreshRelayEnvironments(); - }, [refreshRelayEnvironments]); + let active = true; + if (onDiscoveryReady || !refreshWhileEmpty || document.visibilityState === "visible") { + void refreshRelayEnvironments().then(() => { + if (active) onDiscoveryReady?.(); + }); + } + return () => { + active = false; + }; + }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]); const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { - setConnectingEnvironmentId(environment.environmentId); + setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId])); const result = await connectRelayEnvironment(environment); - setConnectingEnvironmentId(null); + setConnectingEnvironmentIds((current) => { + const next = new Set(current); + next.delete(environment.environmentId); + return next; + }); if (result._tag === "Success") { toastManager.add({ type: "success", title: "Environment added", description: `Connecting to ${environment.label} through Pylon Connect.`, }); - return; + return true; } if (isAtomCommandInterrupted(result)) { - return; + return false; } const cause = squashAtomCommandFailure(result); const message = @@ -125,6 +155,7 @@ export function CloudEnvironmentConnectRows({ } : undefined, }); + return false; }; const visibleEnvironments = [...environmentsState.environments.values()].filter( @@ -132,10 +163,73 @@ export function CloudEnvironmentConnectRows({ environment.environmentId !== primaryEnvironmentId && (showSavedEnvironments || !savedById.has(environment.environmentId)), ); + const selectNewComputers = useEffectEvent(() => { + const seen = selection?.autoSelectedComputers; + if (!selection || !seen) return; + for (const { environment } of visibleEnvironments) { + const id = environment.environmentId; + if (seen.has(id)) continue; + seen.add(id); + selection.onChange(id, true); + if (!savedById.has(id)) { + void connectEnvironment(environment).then((connected) => { + if (!connected) selection.onChange(id, false); + }); + } + } + }); + useEffect(() => { + selectNewComputers(); + }, [environmentsState.environments]); + + // Discovery clears its list on refresh, so poll only until a machine appears. + const shouldRefreshWhileEmpty = + refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; + + useEffect(() => { + if (!shouldRefreshWhileEmpty) return; + let timer: ReturnType | undefined; + let disposed = false; + let pending = false; + const visible = () => document.visibilityState === "visible"; + const schedule = () => { + clearTimeout(timer); + if (!disposed && visible()) { + timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS); + } + }; + const refresh = async () => { + if (disposed || pending || !visible()) return; + clearTimeout(timer); + pending = true; + try { + await refreshDiscoveryWhenIdle(); + } finally { + pending = false; + schedule(); + } + }; + const onFocus = () => void refresh(); + const onVisibilityChange = () => { + clearTimeout(timer); + if (visible()) void refresh(); + }; + + schedule(); + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + disposed = true; + clearTimeout(timer); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [shouldRefreshWhileEmpty]); const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( + !refreshWhileEmpty && standalone && visibleEnvironments.length === 0 && environmentsState.refreshing && @@ -200,6 +294,48 @@ export function CloudEnvironmentConnectRows({ : availability === "checking" ? "Available · Checking relay status…" : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable"); + if (selection) { + return ( + + ); + } return (
@@ -247,10 +383,10 @@ export function CloudEnvironmentConnectRows({ ) : ( )}
diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx new file mode 100644 index 000000000..939dbbcc4 --- /dev/null +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -0,0 +1,236 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useState } from "react"; + +import { + ensureClientSettingsHydrated, + useClientSettings, + useClientSettingsHydrationStatus, +} from "../../hooks/useSettings"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, + type FirstRunGateState, +} from "../../onboarding/firstRun.logic"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { environmentProjects } from "../../state/projects"; +import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; +import { environmentShell } from "../../state/shell"; +import { environmentThreadShells } from "../../state/threads"; +import { Button } from "../ui/button"; + +/** + * Holds back authenticated and hosted app trees until the first-run decision + * is known, so a fresh install never flashes the main screen before the wizard. + * Nothing renders while pending — no shell, no EventRouter (whose welcome + * payload would otherwise navigate into a thread), no dialogs. + * + * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as + * settings hydrate (the common case, no server round-trip). A `null` flag also + * covers installs that predate the field, so it alone is not enough — the gate + * waits for environment shells to bootstrap and inspects the workspace. + * Hosted mode instead checks its saved environment catalog. A timeout shows + * recovery for an unreachable primary server without mounting the app tree. + */ + +const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; + +const primaryShellLiveAtom = Atom.make((get) => { + const serverConfig = get(primaryServerConfigAtom); + return ( + serverConfig !== null && + get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" + ); +}).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); + +const workspaceEvidenceLiveAtom = Atom.make((get) => { + const environmentIds = new Set([ + ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), + ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), + ]); + + for (const environmentId of environmentIds) { + if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { + return false; + } + } + + return true; +}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); + +export function FirstRunGate({ + enabled, + hostedStatic, + children, +}: { + readonly enabled: boolean; + readonly hostedStatic: boolean; + readonly children: React.ReactNode; +}) { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const hydrationStatus = useClientSettingsHydrationStatus(); + const hydrated = hydrationStatus === "ready"; + const completeOnboarding = useCompleteOnboarding(); + const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const projects = useProjects(); + const threads = useThreadShells(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); + const primaryShellLive = useAtomValue(primaryShellLiveAtom); + const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); + // Within a session settings stay hydrated, so remounts (e.g. returning from + // the wizard) resolve synchronously instead of blanking a frame. + const [gateState, setGateState] = useState(() => ({ + decision: + (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) + ? "app" + : "pending", + stalled: false, + })); + const { decision, stalled } = gateState; + const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; + // A workspace still counts as fresh when its only content is the server's + // own cwd auto-bootstrap: web mode creates a project + thread from cwd at + // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no + // projects at all" would mean `npx t3` users never see the wizard. Any + // other project, more than one thread, or state in a non-primary + // environment is real user state — the aggregate hooks span every + // environment, and a saved remote's project must never read as "the + // bootstrap project" just because its root string matches the primary cwd. + const serverCwd = serverConfig?.cwd ?? null; + const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; + const workspaceFresh = isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd, + bootstrapProjectId: serverWelcome?.bootstrapProjectId, + bootstrapThreadId: serverWelcome?.bootstrapThreadId, + bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, + bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, + projects, + threads, + }); + + const { decision: nextDecision, persistCompletion } = hostedStatic + ? resolveHostedFirstRunDecision({ + hydrated, + completed: onboardingCompletedAt !== null, + catalogReady: environmentCatalogReady, + environmentCount: environments.length, + }) + : resolveFirstRunDecision({ + enabled, + hydrated, + completed: onboardingCompletedAt !== null, + bootstrapped, + authoritative: primaryShellLive, + workspaceAuthoritative: workspaceEvidenceLive, + workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: serverWelcome !== null, + bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, + }), + catalogReady: environmentCatalogReady, + serverConfigAvailable: serverConfig !== null, + workspaceFresh, + projectCount: projects.length, + threadCount: threads.length, + }); + + useEffect(() => { + if (decision === "wizard" || !hydrated) return; + + if (persistCompletion && onboardingCompletedAt === null) { + void completeOnboarding().catch(() => undefined); + } + + setGateState((state) => + transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), + ); + }, [ + completeOnboarding, + decision, + hydrated, + nextDecision, + onboardingCompletedAt, + persistCompletion, + ]); + + // A stalled server read gets a recovery screen, but never mounts the app. + // The timer starts after settings hydrate so slow local hydration does not + // show a false connection failure. + useEffect(() => { + if (!enabled || decision !== "pending" || !hydrated) return; + const timer = window.setTimeout( + () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), + FIRST_RUN_DECISION_TIMEOUT_MS, + ); + return () => window.clearTimeout(timer); + }, [decision, enabled, hydrated]); + + useEffect(() => { + if (decision === "wizard" && pathname !== "/welcome") { + void navigate({ to: "/welcome", replace: true }); + } + }, [decision, navigate, pathname]); + + if (settingsReadFailed) { + return ; + } + if (decision !== "app") { + return stalled ? : null; + } + return children; +} + +function FirstRunRecovery({ + reason, + retrying = false, +}: { + readonly reason: "settings" | "connection"; + readonly retrying?: boolean; +}) { + const settingsReadFailed = reason === "settings"; + return ( +
+
+

+ {settingsReadFailed ? "Could not read settings" : "Still connecting"} +

+

+ {settingsReadFailed + ? "Your saved settings could not be loaded." + : "Pylon could not confirm this workspace."} +

+ +
+
+ ); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx new file mode 100644 index 000000000..48631519b --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -0,0 +1,1598 @@ +import { useAuth } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentSessionProjectCandidate, + EnvironmentId, + ProjectId, + ScopedProjectRef, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + ArrowRightIcon, + CheckIcon, + ChevronRightIcon, + CloudIcon, + CopyIcon, + LinkIcon, + LogInIcon, + MonitorIcon, + TerminalIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + groupOnboardingProjects, + partitionOnboardingProjects, + onboardingProjectKey, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, + type OnboardingProjectGroup, +} from "../../onboarding/projectImport.logic"; +import { + getOnboardingProviderState, + resolveOnboardingProviderInstallCommand, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "../../onboarding/providerReadiness.logic"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { newProjectId, randomUUID } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { agentSessionImport } from "../../state/agentSessions"; +import { readProjects, useProjects } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic"; +import { useProjectScans } from "../../onboarding/useProjectScans"; +import { projectEnvironment } from "../../state/projects"; +import { serverEnvironment } from "../../state/server"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { connectPairing } from "../../connection/onboarding"; +import { getProviderSummary } from "../settings/providerStatus"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { ProviderSignInDialog } from "../settings/ProviderSignInDialog"; +import { TerminalViewport } from "../ThreadTerminalDrawer"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { ClaudeAI, OpenAI } from "../Icons"; +import { PylonMark } from "../PylonMark"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Input } from "../ui/input"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { ScrollArea } from "../ui/scroll-area"; +import { Spinner } from "../ui/spinner"; +import { WizardPanel, WizardSteps } from "../ui/wizard"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { toastManager } from "../ui/toast"; +import { cn } from "../../lib/utils"; +import { formatRelativeTime } from "../../timestampFormat"; + +/** + * First-run welcome wizard. Rendered over the workspace at `/welcome` on a + * fresh install (no completed-onboarding flag, empty workspace). Flow per the + * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → + * agent setup with inline install terminal → project import → main screen. + * Every step past the connection gate is skippable; the whole wizard is + * re-runnable by clearing the flag. + */ + +type WizardStep = "connection" | "agents" | "import"; +const NO_ENVIRONMENTS: readonly EnvironmentId[] = []; + +const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); +const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; +const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; + +export function WelcomeWizard({ + localAvailable, + onDone, +}: { + /** Whether this client is authenticated to the server serving the app. */ + readonly localAvailable: boolean; + readonly onDone: (projectRef?: ScopedProjectRef) => void; +}) { + const completeOnboarding = useCompleteOnboarding(); + const [step, setStep] = useState("connection"); + const { environments } = useEnvironments(); + const [selection, setSelection] = useState | null>(null); + const autoSelectedComputers = useRef(new Set()); + const [setupIds, setSetupIds] = useState([]); + const [isImporting, setIsImporting] = useState(false); + const finishingPromiseRef = useRef | null>(null); + const completionErrorToastIdRef = useRef | null>(null); + const primaryEnvironment = usePrimaryEnvironment(); + useEffect(() => { + const newComputers = environments.filter( + (environment) => !autoSelectedComputers.current.has(environment.environmentId), + ); + if (newComputers.length === 0) return; + for (const environment of newComputers) { + autoSelectedComputers.current.add(environment.environmentId); + } + setSelection( + (current) => + new Set([ + ...(current ?? []), + ...newComputers.map((environment) => environment.environmentId), + ]), + ); + }, [environments]); + const selectedIds = + selection ?? new Set(primaryEnvironment ? [primaryEnvironment.environmentId] : []); + const scans = useProjectScans(step === "import" ? setupIds : NO_ENVIRONMENTS); + const isLoadingProjects = + step === "import" && + scans.every((scan) => scan.data === null) && + scans.some((scan) => scan.isPending); + const startSetup = (ids: readonly EnvironmentId[]) => { + if (ids.length === 0) return; + setSetupIds(ids); + setStep("agents"); + }; + const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; + const finish = useCallback( + (projectRef?: ScopedProjectRef) => { + if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + + const completion = completeOnboarding() + .then(() => { + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + onDone(projectRef); + return true; + }) + .catch(() => { + const errorToast = { + type: "error", + title: "Could not finish setup", + description: "Your settings could not be saved. Try again.", + } as const; + if (completionErrorToastIdRef.current === null) { + completionErrorToastIdRef.current = toastManager.add(errorToast); + } else { + toastManager.update(completionErrorToastIdRef.current, errorToast); + } + return false; + }) + .finally(() => { + if (finishingPromiseRef.current === completion) { + finishingPromiseRef.current = null; + } + }); + finishingPromiseRef.current = completion; + return completion; + }, + [completeOnboarding, onDone], + ); + + return ( + event.cancel()}> + document.getElementById("onboarding-pairing-url") ?? true} + > + Set up Pylon +
+ +
+ + + Pylon + +
+ isImporting || index >= stageIndex} + onStepChange={(index) => { + if (isImporting || index > stageIndex) return; + setStep(index === 0 ? "connection" : "agents"); + }} + /> +
+ + + {step === "connection" ? ( + + setSelection((current) => { + const next = new Set(current ?? selectedIds); + if (checked) next.add(environmentId); + else next.delete(environmentId); + return next; + }) + } + onContinue={() => + startSetup( + environments + .filter((environment) => selectedIds.has(environment.environmentId)) + .map((environment) => environment.environmentId), + ) + } + onPaired={(environmentId) => { + setSelection(new Set([...selectedIds, environmentId])); + }} + /> + ) : step === "agents" ? ( + setStep("import")} /> + ) : ( + + )} + +
+
+
+ ); +} + +// ── Step 1: connection choice ──────────────────────────────── + +function ConnectionStep({ + autoSelectedComputers, + expandPairingInitially, + selectedIds, + onSelectionChange, + onToggleEnvironment, + onContinue, + onPaired, +}: { + readonly autoSelectedComputers: Set; + readonly expandPairingInitially: boolean; + readonly selectedIds: ReadonlySet; + readonly onSelectionChange: (ids: ReadonlySet) => void; + readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void; + readonly onContinue: () => void; + readonly onPaired: (environmentId: EnvironmentId) => void; +}) { + const { environments } = useEnvironments(); + const cloudEnabled = hasCloudPublicConfig(); + const directEnvironments = environments.filter( + (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment), + ); + const [pairingOpen, setPairingOpen] = useState(expandPairingInitially); + const [isPairing, setIsPairing] = useState(false); + const ready = + selectedIds.size > 0 && + [...selectedIds].every((id) => + environments.some( + (environment) => + environment.environmentId === id && environment.connection.phase === "connected", + ), + ); + const continueRef = useRef(null); + useEffect(() => { + if ( + ready && + (document.activeElement === document.body || + document.activeElement?.getAttribute("role") === "dialog") + ) { + continueRef.current?.focus(); + } + }, [ready]); + return ( + <> +

+ Connect your computers +

+

+ Choose one or more computers. We’ll set up agents and projects on each. +

+ {directEnvironments.length > 0 ? ( +
+ Computers to set up + {directEnvironments.map((environment) => ( + + ))} +
+ ) : null} +
+ {cloudEnabled ? ( + + ) : null} + + + } + > + + Add a computer + + + +
+ { + setPairingOpen(false); + onPaired(environmentId); + requestAnimationFrame(() => continueRef.current?.focus()); + }} + /> +
+
+
+
+
+ +
+ + ); +} + +function ConnectAccountOption({ + autoSelectedComputers, + disabled, + selectedIds, + onToggleEnvironment, +}: { + readonly autoSelectedComputers: Set; + readonly disabled: boolean; + readonly selectedIds: ReadonlySet; + readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void; +}) { + const { environments } = useEnvironments(); + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const [expanded, setExpanded] = useState(true); + const [discoveryReady, setDiscoveryReady] = useState(false); + const onDiscoveryReady = useCallback(() => setDiscoveryReady(true), []); + + return ( + + { + if (!isSignedIn) { + event.preventDefault(); + setExpanded(true); + openAuthPrompt(); + } + }} + render={ + +
+ +

+ Run this on the computer with your code. +

+ +

+ Start Pylon first, or run npx t3 serve. Add{" "} + --tailscale to use your tailnet. +

+
+ + + + ); +} + +// ── Step 3: agents ─────────────────────────────────────────── + +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; + +/** Setup values stay fixed while provider probes refresh the surrounding cards. */ +interface AgentTerminalSession { + readonly environmentId: EnvironmentId; + readonly driver: OnboardingAgentDriver; + readonly providerInstanceId: ServerProvider["instanceId"]; + readonly cwd: string; + readonly command: string; + readonly keybindings: ServerConfig["keybindings"]; +} + +/** + * Claude Code and Codex use live probe status. Install opens the built-in + * terminal inline with the vendor's standalone installer pre-typed. The update + * RPC can't install a binary that isn't there yet (it infers the installer from + * the installed binary's path). Codex signs in through that terminal; Claude + * Code uses the same sign-in dialog as Settings, which needs no terminal access. + */ +function AgentsStep({ + environmentIds, + onContinue, +}: { + readonly environmentIds: readonly EnvironmentId[]; + readonly onContinue: () => void; +}) { + const { environments } = useEnvironments(); + return ( + + +
+ {environmentIds.map((environmentId) => ( + environment.environmentId === environmentId) + ?.label ?? "Computer" + } + /> + ))} +
+
+
+ +
+
+ ); +} + +function ConnectedAgentsStep({ + environmentId, + machineLabel, +}: { + readonly environmentId: EnvironmentId; + readonly machineLabel: string; +}) { + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const [terminalSession, setTerminalSession] = useState(null); + const [signInProvider, setSignInProvider] = useState(null); + + // Re-probe on entry so freshly installed CLIs show up without a manual + // refresh; harmless when nothing changed (single-flighted per environment). + useEffect(() => { + void refreshProviders({ environmentId, input: {} }); + }, [environmentId, refreshProviders]); + + const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); + + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ + driver, + provider: byDriver.get(driver), + })); + return ( +
+

{machineLabel}

+
+ {primaryAgents.map(({ driver, provider }) => ( + setSignInProvider(provider) + : undefined + } + onOpenTerminal={() => { + if (provider === undefined || serverConfig === null) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : resolveOnboardingProviderInstallCommand( + driver, + serverConfig.environment.platform.os, + ), + keybindings: serverConfig.keybindings, + }); + }} + /> + ))} +
+ {terminalSession !== null ? ( + { + setTerminalSession(null); + void refreshProviders({ environmentId, input: {} }); + }} + /> + ) : null} + {signInProvider !== null ? ( + { + if (!open) setSignInProvider(null); + }} + environmentId={environmentId} + instanceId={signInProvider.instanceId} + accountLabel={signInProvider.displayName ?? "Claude Code"} + knownEmail={signInProvider.auth.email} + onSignedIn={() => void refreshProviders({ environmentId, input: {} })} + /> + ) : null} +
+ ); +} + +function AgentCard({ + driver, + provider, + terminalOpen, + terminalAvailable, + onOpenTerminal, + onSignIn, +}: { + readonly driver: OnboardingAgentDriver; + readonly provider: ServerProvider | undefined; + readonly terminalOpen: boolean; + readonly terminalAvailable: boolean; + readonly onOpenTerminal: () => void; + /** Signs in without a terminal when the provider supports it. */ + readonly onSignIn?: (() => void) | undefined; +}) { + const meta = getDriverOption(ProviderDriverKind.make(driver)); + const Icon = meta?.icon; + const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const summary = getProviderSummary(provider); + const providerState = getOnboardingProviderState(provider); + const signInWithDialog = providerState === "signIn" && onSignIn !== undefined; + + return ( +
+ {Icon ? ( + + ) : null} +
+ {displayName} +

+ {summary.headline} + {summary.detail ? ` · ${summary.detail}` : ""} +

+
+
+ {providerState === "ready" ? ( + + + Ready + + ) : providerState === "checking" ? ( + Checking... + ) : providerState === "disabled" ? ( + Disabled + ) : providerState === "attention" ? ( + {summary.headline} + ) : signInWithDialog ? ( + + ) : ( + + )} +
+
+ ); +} + +/** + * Inline install terminal. Opens a PTY on the connected environment under a + * synthetic onboarding thread id (terminals are keyed by free-form thread id; + * the server validates only the cwd) and pre-types the install or login + * command without submitting, so the user reviews and presses Enter. + */ +function AgentInstallTerminal({ + session, + onClose, +}: { + readonly session: AgentTerminalSession; + readonly onClose: () => void; +}) { + const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session; + // Same terminal typography preference the thread drawer honors. + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); + const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const setupQueueRef = useRef(Promise.resolve()); + const setupGenerationRef = useRef(0); + const activeSetupGenerationRef = useRef(null); + const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), + [environmentId], + ); + const [setupAttempt, setSetupAttempt] = useState(0); + const [setupState, setSetupState] = useState< + "preparing" | "ready" | "openFailed" | "writeFailed" + >("preparing"); + const terminalReady = setupState === "ready" || setupState === "writeFailed"; + + // Keep each setup generation distinct. In Strict Mode, a canceled open can + // finish after the replacement setup starts; it must not close or pre-type + // into the replacement session that shares this terminal id. + useEffect(() => { + const generation = setupGenerationRef.current + 1; + setupGenerationRef.current = generation; + activeSetupGenerationRef.current = generation; + setSetupState("preparing"); + + setupQueueRef.current = setupQueueRef.current.then(async () => { + if (activeSetupGenerationRef.current !== generation) return; + const opened = await openTerminal({ + environmentId, + input: { + threadId: AGENT_ONBOARDING_THREAD_ID, + terminalId, + cwd, + providerInstanceId, + }, + }); + if (opened._tag !== "Success") { + if (activeSetupGenerationRef.current === generation) setSetupState("openFailed"); + return; + } + + if (activeSetupGenerationRef.current !== generation) return; + + const wrote = await writeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, + }); + if (activeSetupGenerationRef.current !== generation) return; + setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed"); + }); + + // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, + // session exit), so this cleanup is the single place the PTY dies — + // nothing is left running behind the wizard. An interrupted install is + // re-runnable from the card. + return () => { + if (activeSetupGenerationRef.current === generation) { + activeSetupGenerationRef.current = null; + } + setupQueueRef.current = setupQueueRef.current.then(async () => { + await closeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true }, + }); + }); + }; + }, [ + closeTerminal, + command, + cwd, + environmentId, + openTerminal, + providerInstanceId, + setupAttempt, + terminalId, + writeTerminal, + ]); + + return ( +
+
+ + {setupState === "writeFailed" ? ( + <> + Run {command} in this + terminal. + + ) : setupState === "ready" ? ( + "Review the command, then press Enter to run it." + ) : setupState === "openFailed" ? ( + "Could not open the setup terminal." + ) : ( + "Preparing command..." + )} + +
+ {setupState === "openFailed" ? ( + + ) : null} + +
+
+
+ {terminalReady ? ( + + ) : null} +
+
+ ); +} + +// ── Step 4: import ─────────────────────────────────────────── + +function ImportStep({ + scans, + isImporting, + setIsImporting, + onDone, +}: { + readonly scans: ReturnType; + readonly isImporting: boolean; + readonly setIsImporting: (value: boolean) => void; + readonly onDone: (projectRef?: ScopedProjectRef) => Promise; +}) { + const { environments } = useEnvironments(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); + const projects = useProjects(); + const [selectedPaths, setSelectedPaths] = useState | null>(null); + const [importError, setImportError] = useState(""); + const [landingProject, setLandingProject] = useState(null); + // Keep project creation attempts separate from completed history imports so both can retry. + const importedProjectsRef = useRef(new Map()); + const projectsWithImportedHistoryRef = useRef(new Map()); + const lastImportSelectionRef = useRef>([]); + const projectAttemptsRef = useRef( + new Map(), + ); + const importGenerationRef = useRef(0); + + // Ignore command completions after leaving the import step. + useEffect(() => { + importGenerationRef.current += 1; + return () => { + importGenerationRef.current += 1; + }; + }, []); + + useEffect(() => { + if ( + landingProject !== null && + projects.some( + (project) => + project.id === landingProject.projectId && + project.environmentId === landingProject.environmentId, + ) + ) { + setLandingProject(null); + void onDone(landingProject).then((completed) => { + if (!completed) setIsImporting(false); + }); + } + }, [landingProject, onDone, projects, setIsImporting]); + + const { available: candidates, recent } = useMemo( + () => + partitionOnboardingProjects( + scans.flatMap((scan) => + (scan.data?.candidates ?? []).map((candidate) => ({ + ...candidate, + environmentId: scan.environmentId, + key: onboardingProjectKey(scan.environmentId, candidate.path), + })), + ), + ), + [scans], + ); + const selectedKeys = useMemo( + () => selectedPaths ?? new Set(recent.map((candidate) => candidate.key)), + [selectedPaths, recent], + ); + const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key)); + + const finishAfterImport = () => { + const projectRef = resolveOnboardingLandingProject( + lastImportSelectionRef.current, + projectsWithImportedHistoryRef.current, + importedProjectsRef.current, + ); + if (projectRef === undefined) { + void onDone(); + return; + } + setIsImporting(true); + setLandingProject(projectRef); + }; + + const runImport = async (selection: typeof candidates) => { + if (isImporting) return; + if (selection.length === 0) { + void onDone(); + return; + } + setIsImporting(true); + setImportError(""); + lastImportSelectionRef.current = selection.map((candidate) => candidate.key); + const importGeneration = importGenerationRef.current; + const importedProjects = importedProjectsRef.current; + const projectAttempts = projectAttemptsRef.current; + // Interrupted imports are neither failures nor successes — the command was + // superseded or the environment dropped — but they still didn't land, so + // they must not read as "imported everything". Retries skip paths that + // already landed this session (re-creating them would only trip the + // duplicate-root invariant and read as a failure). + let importedProjectsCount = + importedProjects.size > 0 + ? selection.filter((candidate) => importedProjects.has(candidate.key)).length + : 0; + let importedThreadCount = 0; + let skippedThreadCount = 0; + const refreshEnvironments = new Set(); + for (const candidate of selection) { + const { environmentId } = candidate; + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (importedProjects.has(candidate.key)) continue; + let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); + if (projectId === null) { + let attempt = projectAttempts.get(candidate.key); + if (attempt === undefined) { + const nextProjectId = newProjectId(); + attempt = { + projectId: nextProjectId, + commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`), + }; + projectAttempts.set(candidate.key, attempt); + } + projectId = attempt.projectId; + const result = await createProject({ + environmentId, + input: { + projectId, + commandId: attempt.commandId, + title: candidate.title, + workspaceRoot: candidate.path, + createWorkspaceRootIfMissing: false, + defaultModelSelection: resolveDefaultProviderModelSelection( + environments.find((environment) => environment.environmentId === environmentId) + ?.serverConfig?.providers ?? [], + null, + ), + }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) { + projectAttempts.delete(candidate.key); + refreshEnvironments.add(environmentId); + } + continue; + } + } + + const threadImportResult = await importThreads({ + environmentId, + input: { projectId, expectedWorkspaceRoot: candidate.path }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (threadImportResult._tag === "Success") { + importedThreadCount += threadImportResult.value.importedCount; + skippedThreadCount += threadImportResult.value.skippedCount; + if (threadImportResult.value.importedCount > 0) { + projectsWithImportedHistoryRef.current.set( + candidate.key, + scopeProjectRef(environmentId, projectId), + ); + } + if (threadImportResult.value.skippedCount === 0) { + importedProjectsCount += 1; + importedProjects.set(candidate.key, scopeProjectRef(environmentId, projectId)); + } + } else if (!isAtomCommandInterrupted(threadImportResult)) { + projectAttempts.delete(candidate.key); + refreshEnvironments.add(environmentId); + } + } + for (const scan of scans) { + if (refreshEnvironments.has(scan.environmentId)) scan.refresh(); + } + setIsImporting(false); + if (importedProjectsCount < selection.length) { + if (importedThreadCount > 0 && skippedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, + ); + } else if (skippedThreadCount > 0) { + setImportError( + `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, + ); + } else if (importedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, + ); + } else { + setImportError("Could not import thread history."); + } + return; + } + finishAfterImport(); + }; + + if (scans.every((scan) => scan.data === null) && scans.some((scan) => scan.isPending)) { + return ( +
+

Your projects

+
+ +

+ Looking for projects from Claude Code and Codex… +

+
+
+ +
+
+ ); + } + + return ( + + {candidates.length > 0 ? ( +
+ + {selected.length} of {candidates.length} selected + +
+ + +
+
+ ) : null} + +
+ {scans.map((scan) => { + const scanCandidates = candidates.filter( + (candidate) => candidate.environmentId === scan.environmentId, + ); + const label = + environments.find((environment) => environment.environmentId === scan.environmentId) + ?.label ?? "Computer"; + return ( +
+ {scans.length > 1 ? ( + {label} + ) : null} + {scan.isPending && scan.data === null ? ( +
+ + Looking for projects… +
+ ) : scan.error !== null ? ( +
+ Could not check projects. {scan.error} + +
+ ) : scanCandidates.length === 0 ? ( +

+ No existing Claude Code or Codex projects found. +

+ ) : null} + {scan.data?.truncated ? ( +

+ {SCAN_LIMIT_MESSAGE} +

+ ) : null} + +
+ ); + })} +
+
+ {importError ?

{importError}

: null} +
+ + +
+
+ ); +} + +type ImportCandidate = AgentSessionProjectCandidate & { + readonly environmentId: EnvironmentId; + readonly key: string; +}; + +/** + * Repositories first, newest activity on top. Clones of one repository share + * a group with a tri-state checkbox. Folders that are not git repositories + * sit collapsed at the bottom so they stay reachable without adding noise. + * Source icons appear only on repository rows so the columns stay still. + */ +function ImportCandidateList({ + candidates, + selectedKeys, + onSelectionChange, +}: { + readonly candidates: ReadonlyArray; + readonly selectedKeys: ReadonlySet; + readonly onSelectionChange: (next: ReadonlySet) => void; +}) { + const { repositories, other } = useMemo(() => groupOnboardingProjects(candidates), [candidates]); + const setKeys = (keys: ReadonlyArray, checked: boolean) => { + const next = new Set(selectedKeys); + for (const key of keys) { + if (checked) next.add(key); + else next.delete(key); + } + onSelectionChange(next); + }; + const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length; + + return ( + <> + {repositories.map((group) => ( + + ))} + {other.length > 0 ? ( + +
+ 0 && otherSelected < other.length} + onCheckedChange={(checked) => + setKeys( + other.map((candidate) => candidate.key), + checked === true, + ) + } + /> + + + Other folders + + {other.length} {other.length === 1 ? "folder" : "folders"} + + +
+ + {other.map((candidate) => ( + setKeys([candidate.key], checked)} + /> + ))} + +
+ ) : null} + + ); +} + +function ImportRepositoryGroup({ + group, + selectedKeys, + onToggle, +}: { + readonly group: OnboardingProjectGroup; + readonly selectedKeys: ReadonlySet; + readonly onToggle: (keys: ReadonlyArray, checked: boolean) => void; +}) { + const keys = group.candidates.map((candidate) => candidate.key); + const selectedCount = keys.filter((key) => selectedKeys.has(key)).length; + const single = group.candidates.length === 1; + const only = group.candidates[0]; + if (single && only !== undefined) { + return ( + onToggle([only.key], checked)} + /> + ); + } + return ( + +
+ 0 && selectedCount < keys.length} + onCheckedChange={(checked) => onToggle(keys, checked === true)} + /> + + + {group.label} + c.sources))]} + threadCount={group.threadCount} + lastActiveAt={group.lastActiveAt} + /> + +
+ + {group.candidates.map((candidate) => ( + onToggle([candidate.key], checked)} + /> + ))} + +
+ ); +} + +function ImportCandidateRow({ + candidate, + label, + secondary, + nested = false, + checked, + onCheckedChange, +}: { + readonly candidate: ImportCandidate; + readonly label: string; + readonly secondary?: string; + readonly nested?: boolean; + readonly checked: boolean; + readonly onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +/** + * Trailing columns shared by every import row: source icons, thread count, + * last activity. Each column has a fixed width and each icon has its own slot + * so nothing shifts between rows that differ in sources or digit count. + */ +function ImportRowMeta({ + sources, + threadCount, + lastActiveAt, +}: { + readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null; + readonly threadCount: number; + readonly lastActiveAt: string | null; +}) { + const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt); + // "just now" does not fit the fixed column, so collapse it. + const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value; + return ( + + + {sources?.includes("claudeAgent") ? ( + + ) : null} + + + {sources?.includes("codex") ? : null} + + {threadCount} + {age} + + ); +} + +// ── Shared bits ────────────────────────────────────────────── + +function StepShell({ + title, + description, + children, +}: { + readonly title: string; + readonly description?: string; + readonly children?: React.ReactNode; +}) { + return ( + <> +

{title}

+ {description ? ( +

{description}

+ ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "command", + }); + return ( +
+ + $ + {command} + + +
+ ); +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx new file mode 100644 index 000000000..75ed20cc4 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -0,0 +1,190 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type ClientSettings, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type PreviewOpenInput, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn(), + open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ), + list: vi.fn(async () => AsyncResult.success(emptyList)), + resize: vi.fn(), + respond: + vi.fn< + (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise + >(), + focus: vi.fn(async () => undefined), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId }] }), +})); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { + automationRequests: () => requestsAtom, + list: () => listAtom, + open: mocks.open, + resize: mocks.resize, + respondToAutomation: mocks.respond, + focusAutomationHost: mocks.focus, + }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => mocks.list, +})); +vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); + +const environmentId = EnvironmentId.make("automation-environment"); +const threadId = ThreadId.make("automation-thread"); +const threadRef = { environmentId, threadId }; +const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; +const savedSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], +}; +const snapshot: PreviewSessionSnapshot = { + threadId, + tabId: "automation-tab", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport, + profileId: "work", + updatedAt: "2026-09-05T00:00:00.000Z", +}; +const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; +const listAtom = Atom.make(AsyncResult.success(emptyList)); +const requestsAtom = Atom.make>( + AsyncResult.initial(false), +); +const requestEvent: PreviewAutomationStreamEvent = { + type: "request", + connectionId: "automation-connection", + request: { + requestId: "open-request", + threadId, + operation: "open", + input: { open: false, reuseExistingTab: false }, + timeoutMs: 15_000, + }, +}; + +function deferred
() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let renderer: ReactTestRenderer | null = null; + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); + mocks.respond.mockReset(); + __resetClientSettingsPersistenceForTests(); + resetPreviewStateForTests(); + appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("PreviewAutomationHosts open", () => { + it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { + const readStarted = deferred(); + const read = deferred(); + const response = deferred(); + mocks.getClientSettings.mockImplementationOnce(() => { + readStarted.resolve(); + return read.promise; + }); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await readStarted.promise; + }); + expect(mocks.open).not.toHaveBeenCalled(); + + await act(async () => { + read.resolve(savedSettings); + await response.promise; + }); + + expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { threadId, viewport, profileId: "work" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); + + it("reports a settings read failure without opening a tab", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await response.promise; + }); + + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: false, + error: { _tag: "PreviewAutomationExecutionError" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b316409..fd87f7e80 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -41,7 +41,11 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const defaults = await resolveBrowserDefaults(); const result = await open({ environmentId, input: { @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 2294aef50..28744e83d 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,7 +1,10 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, @@ -46,6 +49,7 @@ import { } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -185,6 +189,16 @@ export function PreviewView({ return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add({ + type: "error", + title: "Unable to open browser", + description: error.message, + }); + } + } return result._tag === "Success"; }, [open, runtimeTabId, threadRef], diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index f26cb0fff..d34de83a2 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -14,6 +15,7 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { addBrowserSurface } from "./addBrowserSurface"; @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); resetPreviewStateForTests(); useRightPanelStore.setState({ byThreadKey: {} }); }); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 622cdbec2..e0cd83501 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -15,7 +15,7 @@ export async function addBrowserSurface(input: { readonly openPreview: OpenPreviewMutation; /** Omit to use the configured default profile. */ readonly profileId?: string | undefined; -}): Promise> { +}): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index a49acbd86..288db101e 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,7 +14,7 @@ export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index ef3d51a9e..fe1421128 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -7,8 +8,11 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as browserDefaults from "~/browser/browserDefaults"; +import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("openPreviewSession", () => { it("creates an idle tab without recording a recently visited URL", async () => { @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); + + it.each(["session", "link"] as const)( + "does not open a %s with unread settings and uses the saved profile on retry", + async (entryPoint) => { + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const input = { openPreview, threadRef, url: "https://t3.chat/" }; + const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; + + const result = await open(input); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); + expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); + } + expect(openPreview).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + + await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: input.url, + viewport, + profileId: "work", + }, + }); + }, + ); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index deb5465eb..07dab9a0b 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -6,12 +6,15 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { browserDefaultOpenProfileId, browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, -): Promise> { +): Promise> { // Resolved once: a tab opened before client settings hydrate would otherwise // be born at the schema defaults and never corrected. - const defaults = await resolveBrowserDefaults(); + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 330ca5912..9e85fcc4c 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -68,6 +68,34 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it.each(["target", "defaults"] as const)( + "does not open either browser when reading %s fails", + async (setting) => { + const failure = new Error("Settings read failed"); + if (setting === "target") { + linkTargetMocks.preference.mockImplementationOnce(() => { + throw failure; + }); + } else { + browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); + } + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await expect( + openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + forceBrowser: false, + }), + ).rejects.toBe(failure); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + expect(openPreview).not.toHaveBeenCalled(); + }, + ); + it("opens in the system browser while that is the configured target", async () => { linkTargetMocks.preference.mockReturnValue("system"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 57e05efa6..ea3f2bc44 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -64,8 +64,9 @@ export async function openTerminalLinkInPreview( targetOrigin: new URL(input.url).origin, }; + // A failed settings read rejects instead of guessing a browser without the saved preference. + const defaults = await resolveBrowserDefaults(); try { - const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 3d7d5f289..4f5a3147b 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -30,7 +30,7 @@ import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { AnimatedHeight } from "../AnimatedHeight"; +import { WizardPanel } from "../ui/wizard"; import { ADD_PROVIDER_WIZARD_STEPS, resolveWizardNavigation, @@ -274,204 +274,199 @@ export function AddProviderInstanceDialog({ /> -
- -
-
- Driver -
- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 gap-2 sm:grid-cols-2" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - const optionSupport = getDriverMultipleInstancePresentation({ - driver: option.value, - providers: serverProviders, - }); - const optionBlocked = - countEnabledConfiguredInstances(settings, option.value) > 0 && - !optionSupport.supported; - return ( - +
+
+ Driver +
+ setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 gap-2 sm:grid-cols-2" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + const optionSupport = getDriverMultipleInstancePresentation({ + driver: option.value, + providers: serverProviders, + }); + const optionBlocked = + countEnabledConfiguredInstances(settings, option.value) > 0 && + !optionSupport.supported; + return ( + + + + {option.label} + + - - - {option.label} - - - - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; + + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} + + {multipleInstancesBlocked ? ( +

+ {multipleInstanceSupport.reason} +

+ ) : null} +
+ + + + + +
+ Accent color +
+ setAccentColor(event.target.value)} + aria-label="Provider instance accent color" + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" + /> +
+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => { + const selected = accentColor.toLowerCase() === swatch; return ( - - - - {option.label} - - - Coming Soon - - + style={{ backgroundColor: swatch }} + onClick={() => setAccentColor(swatch)} + aria-label={`Use ${swatch} accent`} + /> ); })} - - {multipleInstancesBlocked ? ( -

- {multipleInstanceSupport.reason} -

+
+ {accentColor ? ( + ) : null}
+ + Optional marker shown in the picker. + +
- - -