authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-05 02:22:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 02:22:30-07:00
log09aac71563c66a4f65f6fbe701aa9596cb677767
tree760b4cd35654abbbab050aff3884bd0e75f09f3f
parentc843c19294bcea9a4f5cf19632b135459a16d214
signature Signed by PGP key B5690EEEBB952194

feat(web): first-run welcome wizard with agent setup and project import (#5362)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

122 files changed, 15520 insertions(+), 494 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts+17-6
...@@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer<FileSystem.FileSystem>) => {...@@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer<FileSystem.FileSystem>) => {
54 const environment = yield* DesktopEnvironment.DesktopEnvironment;54 const environment = yield* DesktopEnvironment.DesktopEnvironment;
55 const settings = yield* DesktopClientSettings.DesktopClientSettings;55 const settings = yield* DesktopClientSettings.DesktopClientSettings;
56 return {56 return {
57 result: yield* settings.get,57 result: yield* Effect.result(settings.get),
58 settingsPath: environment.clientSettingsPath,58 settingsPath: environment.clientSettingsPath,
59 records,59 records,
60 };60 };
...@@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => {...@@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => {
73 Effect.gen(function* () {73 Effect.gen(function* () {
74 const result = yield* readWithLogs(FileSystem.layerNoop({}));74 const result = yield* readWithLogs(FileSystem.layerNoop({}));
7575
76 assert.isTrue(Option.isNone(result.result));76 if (result.result._tag !== "Success") return assert.fail("expected a successful read");
77 assert.isTrue(Option.isNone(result.result.success));
77 assert.deepEqual(result.records, []);78 assert.deepEqual(result.records, []);
78 }),79 }),
79 );80 );
8081
81 it.effect("logs non-missing filesystem failures with the settings path", () => {82 it.effect("reports non-missing filesystem failures and logs the settings path", () => {
82 const permissionError = PlatformError.systemError({83 const permissionError = PlatformError.systemError({
83 _tag: "PermissionDenied",84 _tag: "PermissionDenied",
84 module: "FileSystem",85 module: "FileSystem",
...@@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => {...@@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => {
93 }),94 }),
94 );95 );
9596
96 assert.isTrue(Option.isNone(result.result));97 if (result.result._tag !== "Failure") return assert.fail("expected a read failure");
98 assert.instanceOf(
99 result.result.failure,
100 DesktopClientSettings.DesktopClientSettingsReadError,
101 );
102 assert.strictEqual(result.result.failure.cause, permissionError);
97 assert.equal(result.records.length, 1);103 assert.equal(result.records.length, 1);
98 assert.deepEqual(result.records[0]?.message, [104 assert.deepEqual(result.records[0]?.message, [
99 "Could not read desktop client settings.",105 "Could not read desktop client settings.",
...@@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => {...@@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => {
103 });109 });
104 });110 });
105111
106 it.effect("logs malformed settings documents with the settings path", () =>112 it.effect("reports malformed settings documents and logs the settings path", () =>
107 Effect.gen(function* () {113 Effect.gen(function* () {
108 const result = yield* readWithLogs(114 const result = yield* readWithLogs(
109 FileSystem.layerNoop({115 FileSystem.layerNoop({
...@@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => {...@@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => {
111 }),117 }),
112 );118 );
113119
114 assert.isTrue(Option.isNone(result.result));120 if (result.result._tag !== "Failure") return assert.fail("expected a decode failure");
121 assert.instanceOf(
122 result.result.failure,
123 DesktopClientSettings.DesktopClientSettingsReadError,
124 );
125 assert.equal(result.result.failure.operation, "decode-document");
115 assert.equal(result.records.length, 1);126 assert.equal(result.records.length, 1);
116 const message = result.records[0]?.message;127 const message = result.records[0]?.message;
117 if (!Array.isArray(message)) {128 if (!Array.isArray(message)) {
apps/desktop/src/settings/DesktopClientSettings.test.ts+80-12
...@@ -44,6 +44,7 @@ const clientSettings: ClientSettings = {...@@ -44,6 +44,7 @@ const clientSettings: ClientSettings = {
44 fontSizeTerminal: 12,44 fontSizeTerminal: 12,
45 fontSmoothing: true,45 fontSmoothing: true,
46 glassOpacity: 80,46 glassOpacity: 80,
47 onboardingCompletedAt: null,
47 panelAnimationDurationMs: 0,48 panelAnimationDurationMs: 0,
48 planModeEnabled: false,49 planModeEnabled: false,
49 proactivePanelsEnabled: true,50 proactivePanelsEnabled: true,
...@@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => {...@@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => {
136 ),137 ),
137 );138 );
138139
140 for (const failure of [
141 { label: "permission", reason: "PermissionDenied" },
142 { label: "I/O", reason: "Unknown" },
143 ] as const) {
144 it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () =>
145 withClientSettings(
146 Effect.gen(function* () {
147 const environment = yield* DesktopEnvironment.DesktopEnvironment;
148 const fileSystem = yield* FileSystem.FileSystem;
149 const settings = yield* DesktopClientSettings.DesktopClientSettings;
150 const savedSettings = {
151 ...clientSettings,
152 onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
153 };
154 yield* settings.set(savedSettings);
155 const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
156 const cause = PlatformError.systemError({
157 _tag: failure.reason,
158 module: "FileSystem",
159 method: "readFileString",
160 pathOrDescriptor: environment.clientSettingsPath,
161 });
162 let failRead = true;
163 const retryableSettings = yield* DesktopClientSettings.make.pipe(
164 Effect.provideService(
165 FileSystem.FileSystem,
166 FileSystem.FileSystem.of({
167 ...fileSystem,
168 readFileString: (path) =>
169 Effect.suspend(() =>
170 failRead ? Effect.fail(cause) : fileSystem.readFileString(path),
171 ),
172 }),
173 ),
174 );
175
176 const error = yield* retryableSettings.get.pipe(Effect.flip);
177 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
178 assert.equal(error.operation, "read-file");
179 assert.equal(error.path, environment.clientSettingsPath);
180 assert.strictEqual(error.cause, cause);
181 assert.equal(
182 yield* fileSystem.readFileString(environment.clientSettingsPath),
183 savedContents,
184 );
185
186 failRead = false;
187 assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
188 }),
189 ),
190 );
191 }
192
139 it.effect("reports the failed client settings write operation and path", () =>193 it.effect("reports the failed client settings write operation and path", () =>
140 withClientSettings(194 withClientSettings(
141 Effect.gen(function* () {195 Effect.gen(function* () {
...@@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => {...@@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => {
222 ),276 ),
223 );277 );
224278
225 it.effect("treats malformed client settings documents as absent", () =>279 for (const document of [
226 withClientSettings(280 { label: "malformed JSON", contents: "{not-json" },
227 Effect.gen(function* () {281 { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' },
228 const environment = yield* DesktopEnvironment.DesktopEnvironment;282 { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' },
229 const fileSystem = yield* FileSystem.FileSystem;283 ]) {
230 const settings = yield* DesktopClientSettings.DesktopClientSettings;284 it.effect(`reports ${document.label} without treating the settings file as absent`, () =>
231 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });285 withClientSettings(
232 yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json");286 Effect.gen(function* () {
287 const environment = yield* DesktopEnvironment.DesktopEnvironment;
288 const fileSystem = yield* FileSystem.FileSystem;
289 const settings = yield* DesktopClientSettings.DesktopClientSettings;
290 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
291 yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
233292
234 assert.isTrue(Option.isNone(yield* settings.get));293 const error = yield* settings.get.pipe(Effect.flip);
235 }),294 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
236 ),295 assert.equal(error.operation, "decode-document");
237 );296 assert.equal(error.path, environment.clientSettingsPath);
297 assert.instanceOf(error.cause, Schema.SchemaError);
298 assert.equal(
299 yield* fileSystem.readFileString(environment.clientSettingsPath),
300 document.contents,
301 );
302 }),
303 ),
304 );
305 }
238});306});
apps/desktop/src/settings/DesktopClientSettings.ts+42-18
...@@ -12,25 +12,33 @@ import * as Ref from "effect/Ref";...@@ -12,25 +12,33 @@ import * as Ref from "effect/Ref";
1212
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1414
15const ClientSettingsDocumentSchema = Schema.Struct({
16 settings: ClientSettingsSchema,
17});
18
19const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);15const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);
20const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema);16const decodeClientSettingsDocument = Schema.decodeEffect(
21const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect(17 fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)),
22 LegacyClientSettingsDocumentJson,
23);18);
24const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson);19const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema);
25const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> =>20const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) {
26 decodeLegacyClientSettingsDocumentJson(raw).pipe(21 const document = yield* decodeClientSettingsDocument(raw);
27 Effect.map((document) => document.settings),22 // Select the shape before validation so invalid legacy settings cannot become defaults.
28 Effect.catchTags({23 return yield* decodeClientSettingsValue(
29 SchemaError: () => decodeClientSettingsJsonValue(raw),24 Object.hasOwn(document, "settings") ? document.settings : document,
30 }),
31 );25 );
26});
32const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);27const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);
3328
29export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass<DesktopClientSettingsReadError>()(
30 "DesktopClientSettingsReadError",
31 {
32 operation: Schema.Literals(["read-file", "decode-document"]),
33 path: Schema.String,
34 cause: Schema.Defect(),
35 },
36) {
37 override get message(): string {
38 return `Desktop client settings read failed during ${this.operation} at ${this.path}.`;
39 }
40}
41
34const DesktopClientSettingsWriteOperation = Schema.Literals([42const DesktopClientSettingsWriteOperation = Schema.Literals([
35 "create-temporary-file-name",43 "create-temporary-file-name",
36 "encode-document",44 "encode-document",
...@@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<Des...@@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<Des
55export class DesktopClientSettings extends Context.Service<63export class DesktopClientSettings extends Context.Service<
56 DesktopClientSettings,64 DesktopClientSettings,
57 {65 {
58 readonly get: Effect.Effect<Option.Option<ClientSettings>>;66 readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>;
59 readonly set: (67 readonly set: (
60 settings: ClientSettings,68 settings: ClientSettings,
61 ) => Effect.Effect<void, DesktopClientSettingsWriteError>;69 ) => Effect.Effect<void, DesktopClientSettingsWriteError>;
...@@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service<...@@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service<
65const readClientSettings = (73const readClientSettings = (
66 fileSystem: FileSystem.FileSystem,74 fileSystem: FileSystem.FileSystem,
67 settingsPath: string,75 settingsPath: string,
68): Effect.Effect<Option.Option<ClientSettings>> =>76): Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError> =>
69 fileSystem.readFileString(settingsPath).pipe(77 fileSystem.readFileString(settingsPath).pipe(
70 Effect.map(Option.some),78 Effect.map(Option.some),
71 Effect.catchTags({79 Effect.catchTags({
...@@ -74,7 +82,15 @@ const readClientSettings = (...@@ -74,7 +82,15 @@ const readClientSettings = (
74 ? Effect.succeed(Option.none<string>())82 ? Effect.succeed(Option.none<string>())
75 : Effect.logWarning("Could not read desktop client settings.", cause).pipe(83 : Effect.logWarning("Could not read desktop client settings.", cause).pipe(
76 Effect.annotateLogs({ settingsPath }),84 Effect.annotateLogs({ settingsPath }),
77 Effect.as(Option.none<string>()),85 Effect.andThen(
86 Effect.fail(
87 new DesktopClientSettingsReadError({
88 operation: "read-file",
89 path: settingsPath,
90 cause,
91 }),
92 ),
93 ),
78 ),94 ),
79 }),95 }),
80 Effect.flatMap(96 Effect.flatMap(
...@@ -87,7 +103,15 @@ const readClientSettings = (...@@ -87,7 +103,15 @@ const readClientSettings = (
87 SchemaError: (cause) =>103 SchemaError: (cause) =>
88 Effect.logWarning("Could not decode desktop client settings.", cause).pipe(104 Effect.logWarning("Could not decode desktop client settings.", cause).pipe(
89 Effect.annotateLogs({ settingsPath }),105 Effect.annotateLogs({ settingsPath }),
90 Effect.as(Option.none<ClientSettings>()),106 Effect.andThen(
107 Effect.fail(
108 new DesktopClientSettingsReadError({
109 operation: "decode-document",
110 path: settingsPath,
111 cause,
112 }),
113 ),
114 ),
91 ),115 ),
92 }),116 }),
93 ),117 ),
apps/server/src/auth/RpcAuthorization.test.ts+9
...@@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => {...@@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => {
43 );43 );
44 });44 });
4545
46 it("requires write access to import agent session history", () => {
47 expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe(
48 AuthOrchestrationReadScope,
49 );
50 expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe(
51 AuthOrchestrationOperateScope,
52 );
53 });
54
46 it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {55 it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
47 // The candidate list is a read like the detail beside it, and asking somebody for a review is56 // The candidate list is a read like the detail beside it, and asking somebody for a review is
48 // a write like every other pull request operation.57 // a write like every other pull request operation.
apps/server/src/auth/RpcAuthorization.ts+2
...@@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = {
99 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,99 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
100 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,100 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
101 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,101 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
102 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
103 [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
102 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,104 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
103 [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,105 [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
104 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,106 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
...@@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => {
90 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),90 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
91 getProjectShellById: () => Effect.succeed(Option.none()),91 getProjectShellById: () => Effect.succeed(Option.none()),
92 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),92 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
93 getImportedAgentSessionSources: () => Effect.die("unused"),
93 getThreadCheckpointContext: () =>94 getThreadCheckpointContext: () =>
94 Effect.sync(() => {95 Effect.sync(() => {
95 getThreadCheckpointContextCalls += 1;96 getThreadCheckpointContextCalls += 1;
...@@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => {
202 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),203 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
203 getProjectShellById: () => Effect.succeed(Option.none()),204 getProjectShellById: () => Effect.succeed(Option.none()),
204 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),205 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
206 getImportedAgentSessionSources: () => Effect.die("unused"),
205 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),207 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
206 getFullThreadDiffContext: () => Effect.die("unused"),208 getFullThreadDiffContext: () => Effect.die("unused"),
207 getThreadRuntimeContext: () => Effect.die("unused"),209 getThreadRuntimeContext: () => Effect.die("unused"),
...@@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => {
289 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),291 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
290 getProjectShellById: () => Effect.succeed(Option.none()),292 getProjectShellById: () => Effect.succeed(Option.none()),
291 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),293 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
294 getImportedAgentSessionSources: () => Effect.die("unused"),
292 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),295 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
293 getFullThreadDiffContext: () => Effect.die("unused"),296 getFullThreadDiffContext: () => Effect.die("unused"),
294 getThreadRuntimeContext: () => Effect.die("unused"),297 getThreadRuntimeContext: () => Effect.die("unused"),
...@@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => {
361 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),364 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
362 getProjectShellById: () => Effect.succeed(Option.none()),365 getProjectShellById: () => Effect.succeed(Option.none()),
363 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),366 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
367 getImportedAgentSessionSources: () => Effect.die("unused"),
364 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),368 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
365 getFullThreadDiffContext: () => Effect.die("unused"),369 getFullThreadDiffContext: () => Effect.die("unused"),
366 getThreadRuntimeContext: () => Effect.die("unused"),370 getThreadRuntimeContext: () => Effect.die("unused"),
...@@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => {
418 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),422 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
419 getProjectShellById: () => Effect.succeed(Option.none()),423 getProjectShellById: () => Effect.succeed(Option.none()),
420 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),424 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
425 getImportedAgentSessionSources: () => Effect.die("unused"),
421 getThreadCheckpointContext: () => Effect.succeed(Option.none()),426 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
422 getFullThreadDiffContext: () => Effect.succeed(Option.none()),427 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
423 getThreadRuntimeContext: () => Effect.die("unused"),428 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+30
...@@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => {...@@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => {
1342 ).toBe("v1\n");1342 ).toBe("v1\n");
1343 });1343 });
13441344
1345 it("does not create checkpoints while importing historical user messages", async () => {
1346 const harness = await createHarness({
1347 hasSession: false,
1348 seedFilesystemCheckpoints: false,
1349 threadWorktreePath: null,
1350 });
1351 if (runtime === null) throw new Error("Checkpoint test runtime was not initialized.");
1352
1353 await runtime.runPromise(
1354 harness.engine.dispatch({
1355 type: "thread.history.import",
1356 commandId: CommandId.make("cmd-import-history-without-checkpoint"),
1357 threadId: ThreadId.make("thread-1"),
1358 messages: [
1359 {
1360 messageId: MessageId.make("imported-user-message"),
1361 role: "user",
1362 text: "A message from an existing agent session",
1363 createdAt: "2026-01-01T00:00:00.000Z",
1364 },
1365 ],
1366 }),
1367 );
1368 await harness.drain();
1369
1370 expect(
1371 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
1372 ).toBe(false);
1373 });
1374
1345 it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => {1375 it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => {
1346 const harness = await createHarness({1376 const harness = await createHarness({
1347 hasSession: false,1377 hasSession: false,
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1
...@@ -615,6 +615,7 @@ const make = Effect.gen(function* () {...@@ -615,6 +615,7 @@ const make = Effect.gen(function* () {
615 ) {615 ) {
616 if (event.type === "thread.message-sent") {616 if (event.type === "thread.message-sent") {
617 if (617 if (
618 event.metadata.historyImport === true ||
618 event.payload.role !== "user" ||619 event.payload.role !== "user" ||
619 event.payload.streaming ||620 event.payload.streaming ||
620 event.payload.turnId !== null621 event.payload.turnId !== null
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
...@@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => {...@@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => {
418 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),418 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
419 getProjectShellById: () => Effect.succeed(Option.none()),419 getProjectShellById: () => Effect.succeed(Option.none()),
420 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),420 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
421 getImportedAgentSessionSources: () => Effect.die("unused"),
421 getThreadCheckpointContext: () => Effect.succeed(Option.none()),422 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
422 getFullThreadDiffContext: () => Effect.succeed(Option.none()),423 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
423 getThreadRuntimeContext: () => Effect.die("unused"),424 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+96
...@@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs...@@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs
108 },108 },
109);109);
110110
111it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))(
112 "imported thread shell projection",
113 (it) => {
114 it.effect("does not mark imported user messages as queued work in thread shells", () =>
115 Effect.gen(function* () {
116 const projectionPipeline = yield* OrchestrationProjectionPipeline;
117 const eventStore = yield* OrchestrationEventStore;
118 const sql = yield* SqlClient.SqlClient;
119 const createdAt = "2026-08-24T10:00:00.000Z";
120 const threadId = ThreadId.make("import:codex:shell-session");
121
122 yield* eventStore.append({
123 type: "thread.created",
124 eventId: EventId.make("evt-import-shell-thread"),
125 aggregateKind: "thread",
126 aggregateId: threadId,
127 occurredAt: createdAt,
128 commandId: CommandId.make("cmd-import-shell-thread"),
129 causationEventId: null,
130 correlationId: CommandId.make("cmd-import-shell-thread"),
131 metadata: {},
132 payload: {
133 threadId,
134 projectId: ProjectId.make("project-import-shell"),
135 title: "Imported thread",
136 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
137 runtimeMode: "full-access",
138 interactionMode: "default",
139 branch: null,
140 worktreePath: null,
141 createdAt,
142 updatedAt: createdAt,
143 },
144 });
145 yield* eventStore.append({
146 type: "thread.message-sent",
147 eventId: EventId.make("evt-import-shell-message"),
148 aggregateKind: "thread",
149 aggregateId: threadId,
150 occurredAt: createdAt,
151 commandId: CommandId.make("cmd-import-shell-message"),
152 causationEventId: null,
153 correlationId: CommandId.make("cmd-import-shell-message"),
154 metadata: { historyImport: true },
155 payload: {
156 threadId,
157 messageId: MessageId.make("import:codex:shell-session:0"),
158 role: "user",
159 text: "Imported user prompt",
160 turnId: null,
161 streaming: false,
162 createdAt,
163 updatedAt: createdAt,
164 },
165 });
166
167 yield* projectionPipeline.bootstrap;
168
169 const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>`
170 SELECT latest_user_message_at AS "latestUserMessageAt"
171 FROM projection_threads
172 WHERE thread_id = ${threadId}
173 `;
174 assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]);
175
176 const sessionEvent = yield* eventStore.append({
177 type: "thread.session-set",
178 eventId: EventId.make("evt-import-shell-session"),
179 aggregateKind: "thread",
180 aggregateId: threadId,
181 occurredAt: createdAt,
182 commandId: CommandId.make("cmd-import-shell-session"),
183 causationEventId: null,
184 correlationId: CommandId.make("cmd-import-shell-session"),
185 metadata: {},
186 payload: {
187 threadId,
188 session: {
189 threadId,
190 status: "ready",
191 providerName: "codex",
192 providerInstanceId: ProviderInstanceId.make("codex"),
193 runtimeMode: "full-access",
194 activeTurnId: null,
195 lastError: null,
196 updatedAt: createdAt,
197 },
198 },
199 });
200 yield* projectionPipeline.projectEvent(sessionEvent);
201 assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]);
202 }),
203 );
204 },
205);
206
111it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {207it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
112 it.effect("bootstraps all projection states and writes projection rows", () =>208 it.effect("bootstraps all projection states and writes projection rows", () =>
113 Effect.gen(function* () {209 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+14-5
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import {1import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 isImportedAgentSessionMessageId,
3 type ChatAttachment,4 type ChatAttachment,
4 type OrchestrationEvent,5 type OrchestrationEvent,
5 type OrchestrationSessionStatus,6 type OrchestrationSessionStatus,
6 ThreadId,7 ThreadId,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
...@@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert(...@@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert(
248 }250 }
249251
250 for (const message of messages) {252 for (const message of messages) {
251 if (message.role === "system") {253 if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) {
252 retainedMessageIds.add(message.messageId);254 retainedMessageIds.add(message.messageId);
253 continue;255 continue;
254 }256 }
...@@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert(...@@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert(
258 }260 }
259261
260 const retainedUserCount = messages.filter(262 const retainedUserCount = messages.filter(
261 (message) => message.role === "user" && retainedMessageIds.has(message.messageId),263 (message) =>
264 message.role === "user" &&
265 !isImportedAgentSessionMessageId(message.messageId) &&
266 retainedMessageIds.has(message.messageId),
262 ).length;267 ).length;
263 const missingUserCount = Math.max(0, turnCount - retainedUserCount);268 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
264 if (missingUserCount > 0) {269 if (missingUserCount > 0) {
...@@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert(...@@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert(
271 )276 )
272 .toSorted(277 .toSorted(
273 (left, right) =>278 (left, right) =>
274 left.createdAt.localeCompare(right.createdAt) ||279 compareDateTimeStrings(left.createdAt, right.createdAt) ||
275 left.messageId.localeCompare(right.messageId),280 left.messageId.localeCompare(right.messageId),
276 )281 )
277 .slice(0, missingUserCount);282 .slice(0, missingUserCount);
...@@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert(...@@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert(
281 }286 }
282287
283 const retainedAssistantCount = messages.filter(288 const retainedAssistantCount = messages.filter(
284 (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId),289 (message) =>
290 message.role === "assistant" &&
291 !isImportedAgentSessionMessageId(message.messageId) &&
292 retainedMessageIds.has(message.messageId),
285 ).length;293 ).length;
286 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);294 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
287 if (missingAssistantCount > 0) {295 if (missingAssistantCount > 0) {
...@@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert(...@@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert(
294 )302 )
295 .toSorted(303 .toSorted(
296 (left, right) =>304 (left, right) =>
297 left.createdAt.localeCompare(right.createdAt) ||305 compareDateTimeStrings(left.createdAt, right.createdAt) ||
298 left.messageId.localeCompare(right.messageId),306 left.messageId.localeCompare(right.messageId),
299 )307 )
300 .slice(0, missingAssistantCount);308 .slice(0, missingAssistantCount);
...@@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
903 updatedAt: event.occurredAt,911 updatedAt: event.occurredAt,
904 latestUserMessageAt:912 latestUserMessageAt:
905 event.payload.role === "user" &&913 event.payload.role === "user" &&
914 !isImportedAgentSessionMessageId(event.payload.messageId) &&
906 (previousLatest === null || event.payload.createdAt > previousLatest)915 (previousLatest === null || event.payload.createdAt > previousLatest)
907 ? event.payload.createdAt916 ? event.payload.createdAt
908 : previousLatest,917 : previousLatest,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+298-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type AgentSessionImportSource,
2 CheckpointRef,3 CheckpointRef,
3 EventId,4 EventId,
4 MessageId,5 MessageId,
...@@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest";...@@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest";
11import * as NodeServices from "@effect/platform-node/NodeServices";12import * as NodeServices from "@effect/platform-node/NodeServices";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
15import * as Schema from "effect/Schema";
14import * as SqlClient from "effect/unstable/sql/SqlClient";16import * as SqlClient from "effect/unstable/sql/SqlClient";
1517
16import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";18import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
...@@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2134 //2136 //
2135 // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id)2137 // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id)
2136 // and a turnless activity at T03.6 — both belong to the page containing T03+.2138 // and a turnless activity at T03.6 — both belong to the page containing T03+.
2137 const seedFanOutThread = Effect.fnUntraced(function* () {2139 const seedFanOutThread = Effect.fnUntraced(function* (options?: {
2140 readonly importedMessageCount?: number;
2141 }) {
2138 const sql = yield* SqlClient.SqlClient;2142 const sql = yield* SqlClient.SqlClient;
21392143
2140 // Tests in this block share one in-memory database; reset before seeding.2144 // Tests in this block share one in-memory database; reset before seeding.
...@@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2163 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL)2167 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL)
2164 `;2168 `;
21652169
2170 if (options?.importedMessageCount) {
2171 for (let index = 0; index < options.importedMessageCount; index += 1) {
2172 const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`;
2173 const role = index % 2 === 0 ? "user" : "assistant";
2174 yield* sql`
2175 INSERT INTO projection_thread_messages (
2176 message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
2177 )
2178 VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0,
2179 '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z')
2180 `;
2181 }
2182 }
2183
2166 const turns: ReadonlyArray<{2184 const turns: ReadonlyArray<{
2167 turn: string;2185 turn: string;
2168 pendingMessage: string | null;2186 pendingMessage: string | null;
...@@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2396 }),2414 }),
2397 );2415 );
23982416
2417 it.effect("keeps imported history on the oldest page after resumed turns", () =>
2418 Effect.gen(function* () {
2419 yield* seedFanOutThread({ importedMessageCount: 12 });
2420 const snapshotQuery = yield* ProjectionSnapshotQuery;
2421
2422 const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 });
2423 assert.equal(completePage._tag, "Some");
2424 if (completePage._tag !== "Some") return;
2425 assert.equal(
2426 completePage.value.thread.messages.filter((message) => message.id.startsWith("import:"))
2427 .length,
2428 12,
2429 );
2430 assert.equal(completePage.value.page?.hasMore, false);
2431 assert.equal(completePage.value.page?.beforeCursor, null);
2432
2433 const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 });
2434 assert.equal(recentPage._tag, "Some");
2435 if (recentPage._tag !== "Some") return;
2436 assert.equal(
2437 recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")),
2438 false,
2439 );
2440 const cursor = recentPage.value.page?.beforeCursor;
2441 assert.notEqual(cursor, null);
2442 assert.notEqual(cursor, undefined);
2443 if (cursor === null || cursor === undefined) return;
2444
2445 const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
2446 turnLimit: 1,
2447 beforeCursor: cursor,
2448 });
2449 assert.equal(oldestPage._tag, "Some");
2450 if (oldestPage._tag !== "Some") return;
2451
2452 const importedIds = oldestPage.value.thread.messages
2453 .map((message) => message.id)
2454 .filter((messageId) => messageId.startsWith("import:"));
2455 assert.equal(importedIds.length, 12);
2456 assert.equal(new Set(importedIds).size, 12);
2457 assert.equal(oldestPage.value.page?.hasMore, false);
2458 assert.equal(oldestPage.value.page?.beforeCursor, null);
2459 }),
2460 );
2461
2399 it.effect("a cursor for a different thread degrades to the first page", () =>2462 it.effect("a cursor for a different thread degrades to the first page", () =>
2400 Effect.gen(function* () {2463 Effect.gen(function* () {
2401 yield* seedFanOutThread();2464 yield* seedFanOutThread();
...@@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2779 }),2842 }),
2780 );2843 );
2781});2844});
2845
2846projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => {
2847 const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
2848 const source: AgentSessionImportSource = {
2849 provider: "codex",
2850 providerInstanceId: ProviderInstanceId.make("codex-home"),
2851 providerSessionId: "native-session",
2852 filePath: "/tmp/transcript.jsonl",
2853 size: 128,
2854 mtimeMs: 1_700_000_000_000,
2855 device: 1,
2856 inode: 2,
2857 birthtimeMs: 1_699_000_000_000,
2858 };
2859
2860 const seedImportedSession = Effect.fn("seedImportedSession")(function* (
2861 projectId: ProjectId,
2862 source: AgentSessionImportSource,
2863 ) {
2864 const sql = yield* SqlClient.SqlClient;
2865 const threadId = ThreadId.make(
2866 `import:${source.providerInstanceId}:${source.providerSessionId}`,
2867 );
2868 const timestamp = "2026-03-02T00:00:00.000Z";
2869 yield* sql`
2870 INSERT OR IGNORE INTO projection_projects (
2871 project_id, title, workspace_root, scripts_json, created_at, updated_at
2872 ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]',
2873 ${timestamp}, ${timestamp})
2874 `;
2875 yield* sql`
2876 INSERT INTO projection_threads (
2877 thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
2878 created_at, updated_at
2879 ) VALUES (${threadId}, ${projectId}, 'Imported thread',
2880 ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })},
2881 'full-access', 'default',
2882 ${timestamp}, ${timestamp})
2883 `;
2884 yield* sql`
2885 INSERT INTO provider_session_runtime (
2886 thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status,
2887 last_seen_at, resume_cursor_json, runtime_payload_json
2888 ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId},
2889 ${source.provider}, 'full-access', 'stopped', ${timestamp},
2890 ${encodeJson({ threadId: source.providerSessionId })},
2891 ${encodeJson({ importedTranscripts: [source] })})
2892 `;
2893 yield* sql`
2894 INSERT INTO projection_thread_messages (
2895 message_id, thread_id, role, text, is_streaming, created_at, updated_at
2896 ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0,
2897 ${timestamp}, ${timestamp})
2898 `;
2899 return { threadId, source };
2900 });
2901
2902 it.effect("reads completed source copies without decoding message bodies", () =>
2903 Effect.gen(function* () {
2904 const sql = yield* SqlClient.SqlClient;
2905 const query = yield* ProjectionSnapshotQuery;
2906 const projectId = ProjectId.make("project-import-metadata");
2907 const imported = yield* seedImportedSession(projectId, source);
2908 const copiedSource = {
2909 ...source,
2910 filePath: "/tmp/transcript-copy.jsonl",
2911 mtimeMs: null,
2912 inode: null,
2913 birthtimeMs: null,
2914 };
2915 yield* sql`
2916 UPDATE provider_session_runtime
2917 SET runtime_payload_json = ${encodeJson({
2918 cwd: "/tmp/imported-project",
2919 importedTranscripts: [source, copiedSource],
2920 })}
2921 WHERE thread_id = ${imported.threadId}
2922 `;
2923 yield* sql`
2924 UPDATE projection_thread_messages SET attachments_json = 'not-json'
2925 WHERE thread_id = ${imported.threadId}
2926 `;
2927
2928 const counter = makeSqlStatementCounter();
2929 const sources = yield* query
2930 .getImportedAgentSessionSources(projectId)
2931 .pipe(Effect.withTracer(counter.tracer));
2932 assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]);
2933 assert.equal(counter.count(), 1);
2934 }),
2935 );
2936
2937 it.effect("requires active project threads, a binding, and an imported message", () =>
2938 Effect.gen(function* () {
2939 const sql = yield* SqlClient.SqlClient;
2940 const query = yield* ProjectionSnapshotQuery;
2941 const projectId = ProjectId.make("project-import-completion");
2942 const completed = yield* seedImportedSession(projectId, {
2943 ...source,
2944 providerSessionId: "completed",
2945 });
2946 yield* sql`
2947 UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`}
2948 WHERE thread_id = ${completed.threadId}
2949 `;
2950 const partials = yield* Effect.forEach(
2951 [
2952 "no-binding",
2953 "no-history",
2954 "no-imported-message",
2955 "wrong-message-thread",
2956 "archived",
2957 "deleted",
2958 ],
2959 (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }),
2960 );
2961 const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] =
2962 partials;
2963 assert.isDefined(noBinding);
2964 assert.isDefined(noHistory);
2965 assert.isDefined(noImportedMessage);
2966 assert.isDefined(wrongMessageThread);
2967 assert.isDefined(archived);
2968 assert.isDefined(deleted);
2969 yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`;
2970 yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`;
2971 yield* sql`
2972 UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`}
2973 WHERE thread_id = ${noImportedMessage.threadId}
2974 `;
2975 yield* sql`
2976 UPDATE projection_thread_messages SET thread_id = 'unrelated-thread'
2977 WHERE thread_id = ${wrongMessageThread.threadId}
2978 `;
2979 yield* sql`
2980 UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z'
2981 WHERE thread_id = ${archived.threadId}
2982 `;
2983 yield* sql`
2984 UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z'
2985 WHERE thread_id = ${deleted.threadId}
2986 `;
2987 const otherProjectId = ProjectId.make("project-import-other");
2988 const otherProject = yield* seedImportedSession(otherProjectId, {
2989 ...source,
2990 providerSessionId: "other-project",
2991 });
2992 const deletedProjectId = ProjectId.make("project-import-deleted");
2993 yield* seedImportedSession(deletedProjectId, {
2994 ...source,
2995 providerSessionId: "deleted-project",
2996 });
2997 yield* sql`
2998 UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z'
2999 WHERE project_id = ${deletedProjectId}
3000 `;
3001
3002 assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]);
3003 assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]);
3004 assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []);
3005 assert.deepEqual(
3006 yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")),
3007 [],
3008 );
3009 }),
3010 );
3011
3012 it.effect("keeps original sources when the current runtime provider and cursor change", () =>
3013 Effect.gen(function* () {
3014 const sql = yield* SqlClient.SqlClient;
3015 const query = yield* ProjectionSnapshotQuery;
3016 const projectId = ProjectId.make("project-import-switched");
3017 const imported = yield* seedImportedSession(projectId, {
3018 ...source,
3019 provider: "claudeAgent",
3020 providerInstanceId: ProviderInstanceId.make("claude-original"),
3021 providerSessionId: "original-session",
3022 });
3023 yield* sql`
3024 UPDATE provider_session_runtime
3025 SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex',
3026 resume_cursor_json = '{"threadId":"new-session"}'
3027 WHERE thread_id = ${imported.threadId}
3028 `;
3029
3030 assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]);
3031 }),
3032 );
3033
3034 it.effect("skips invalid source payloads and entries without dropping valid sources", () =>
3035 Effect.gen(function* () {
3036 const sql = yield* SqlClient.SqlClient;
3037 const query = yield* ProjectionSnapshotQuery;
3038 const projectId = ProjectId.make("project-import-invalid");
3039 const imported = yield* seedImportedSession(projectId, {
3040 ...source,
3041 providerSessionId: "a-invalid",
3042 });
3043 const valid = yield* seedImportedSession(projectId, {
3044 ...source,
3045 providerSessionId: "z-valid",
3046 });
3047 for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) {
3048 yield* sql`
3049 UPDATE provider_session_runtime SET runtime_payload_json = ${payload}
3050 WHERE thread_id = ${imported.threadId}
3051 `;
3052 assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]);
3053 }
3054 yield* sql`
3055 UPDATE provider_session_runtime SET runtime_payload_json = X'FF'
3056 WHERE thread_id = ${imported.threadId}
3057 `;
3058 assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]);
3059
3060 yield* sql`
3061 UPDATE provider_session_runtime
3062 SET runtime_payload_json = ${encodeJson({
3063 importedTranscripts: [
3064 null,
3065 {},
3066 { ...imported.source, size: -1 },
3067 { ...imported.source, provider: "cursor" },
3068 { ...imported.source, providerInstanceId: "wrong-instance" },
3069 { ...imported.source, providerSessionId: "wrong-session" },
3070 imported.source,
3071 ],
3072 })}
3073 WHERE thread_id = ${imported.threadId}
3074 `;
3075 assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]);
3076 }),
3077 );
3078});
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+89-20
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 AgentSessionImportSource,
2 ApprovalRequestId,3 ApprovalRequestId,
3 ChatAttachment,4 ChatAttachment,
4 CheckpointRef,5 CheckpointRef,
...@@ -75,6 +76,14 @@ import {...@@ -75,6 +76,14 @@ import {
75const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);76const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
76const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);77const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);
77const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);78const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);
79const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption(
80 Schema.fromJsonString(
81 Schema.Struct({
82 importedTranscripts: Schema.Array(Schema.Unknown),
83 }),
84 ),
85);
86const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource);
78// Keep detail reads consistent with the in-memory projector's retained87// Keep detail reads consistent with the in-memory projector's retained
79// activity window. Applying the limit in SQL avoids decoding an unbounded88// activity window. Applying the limit in SQL avoids decoding an unbounded
80// payload_json set before the projector can enforce that invariant.89// payload_json set before the projector can enforce that invariant.
...@@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({...@@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({
164const ProjectIdLookupInput = Schema.Struct({173const ProjectIdLookupInput = Schema.Struct({
165 projectId: ProjectId,174 projectId: ProjectId,
166});175});
176const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({
177 threadId: ThreadId,
178 runtimePayload: Schema.Unknown,
179});
167const ThreadIdLookupInput = Schema.Struct({180const ThreadIdLookupInput = Schema.Struct({
168 threadId: ThreadId,181 threadId: ThreadId,
169});182});
...@@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
983 `,996 `,
984 });997 });
985998
999 const listImportedAgentSessionSourceRows = SqlSchema.findAll({
1000 Request: ProjectIdLookupInput,
1001 Result: ProjectionImportedAgentSessionSourcesRowSchema,
1002 execute: ({ projectId }) =>
1003 sql`
1004 SELECT
1005 threads.thread_id AS "threadId",
1006 runtime.runtime_payload_json AS "runtimePayload"
1007 FROM projection_threads AS threads
1008 INNER JOIN projection_projects AS projects
1009 ON projects.project_id = threads.project_id
1010 INNER JOIN provider_session_runtime AS runtime
1011 ON runtime.thread_id = threads.thread_id
1012 WHERE threads.project_id = ${projectId}
1013 AND threads.deleted_at IS NULL
1014 AND threads.archived_at IS NULL
1015 AND projects.deleted_at IS NULL
1016 AND EXISTS (
1017 SELECT 1
1018 FROM projection_thread_messages AS messages
1019 WHERE messages.thread_id = threads.thread_id
1020 AND messages.message_id GLOB 'import:*'
1021 )
1022 ORDER BY threads.thread_id ASC
1023 `,
1024 });
1025
986 const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({1026 const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({
987 Request: ThreadIdLookupInput,1027 Request: ThreadIdLookupInput,
988 Result: ProjectionThreadCheckpointContextThreadRowSchema,1028 Result: ProjectionThreadCheckpointContextThreadRowSchema,
...@@ -2663,6 +2703,33 @@ pending_approval_requests AS (...@@ -2663,6 +2703,33 @@ pending_approval_requests AS (
2663 Effect.map(Option.map((row) => row.threadId)),2703 Effect.map(Option.map((row) => row.threadId)),
2664 );2704 );
26652705
2706 const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] =
2707 Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) {
2708 const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe(
2709 Effect.mapError(
2710 toPersistenceSqlOrDecodeError(
2711 "ProjectionSnapshotQuery.getImportedAgentSessionSources:query",
2712 "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows",
2713 ),
2714 ),
2715 );
2716 return rows.flatMap((row) => {
2717 const payload = decodeImportedTranscriptsPayload(row.runtimePayload);
2718 if (Option.isNone(payload)) return [];
2719 return payload.value.importedTranscripts.flatMap((entry) => {
2720 const source = decodeAgentSessionImportSource(entry);
2721 if (
2722 Option.isNone(source) ||
2723 row.threadId !==
2724 `import:${source.value.providerInstanceId}:${source.value.providerSessionId}`
2725 ) {
2726 return [];
2727 }
2728 return [{ threadId: row.threadId, source: source.value }];
2729 });
2730 });
2731 });
2732
2666 const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = (2733 const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = (
2667 threadId,2734 threadId,
2668 ) =>2735 ) =>
...@@ -3151,17 +3218,35 @@ pending_approval_requests AS (...@@ -3151,17 +3218,35 @@ pending_approval_requests AS (
3151 );3218 );
31523219
3153 const oldest = windowRows[0];3220 const oldest = windowRows[0];
3221 const hasMore =
3222 oldest !== undefined &&
3223 (yield* listTurnWindowRows({
3224 threadId,
3225 beforeAnchorAt: oldest.anchorAt,
3226 beforeTurnKey: oldest.turnKey,
3227 userTurnLimit: 1,
3228 maxRawTurns: 1,
3229 }).pipe(
3230 Effect.mapError(
3231 toPersistenceSqlOrDecodeError(
3232 "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query",
3233 "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows",
3234 ),
3235 ),
3236 )).length > 0;
3154 // An empty window (no turns before the cursor, or a thread with no3237 // An empty window (no turns before the cursor, or a thread with no
3155 // turns at all) still returns thread metadata with empty collections3238 // turns at all) still returns thread metadata with empty collections
3156 // for turn-linked rows; turnless rows are bounded to the same empty3239 // for turn-linked rows; turnless rows are bounded to the same empty
3157 // range. The first page of a turnless thread stays unwindowed so3240 // range. The first page of a turnless thread stays unwindowed so
3158 // pre-turn content (e.g. a just-created thread) is not hidden.3241 // pre-turn content (e.g. a just-created thread) is not hidden. Once
3242 // paging reaches the oldest turn, include turnless messages before
3243 // the first turn, such as history imported from a provider session.
3159 const bounds: ThreadDetailBounds | undefined =3244 const bounds: ThreadDetailBounds | undefined =
3160 oldest === undefined && cursor === null3245 oldest === undefined && cursor === null
3161 ? undefined3246 ? undefined
3162 : {3247 : {
3163 minAnchorAt: oldest?.anchorAt ?? "",3248 minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "",
3164 minTurnKey: oldest?.turnKey ?? "",3249 minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "",
3165 beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED,3250 beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED,
3166 beforeTurnKey: cursor?.beforeTurnId ?? "",3251 beforeTurnKey: cursor?.beforeTurnId ?? "",
3167 };3252 };
...@@ -3178,23 +3263,6 @@ pending_approval_requests AS (...@@ -3178,23 +3263,6 @@ pending_approval_requests AS (
3178 return Option.none<OrchestrationThreadDetailSnapshot>();3263 return Option.none<OrchestrationThreadDetailSnapshot>();
3179 }3264 }
31803265
3181 const hasMore =
3182 oldest !== undefined &&
3183 (yield* listTurnWindowRows({
3184 threadId,
3185 beforeAnchorAt: oldest.anchorAt,
3186 beforeTurnKey: oldest.turnKey,
3187 userTurnLimit: 1,
3188 maxRawTurns: 1,
3189 }).pipe(
3190 Effect.mapError(
3191 toPersistenceSqlOrDecodeError(
3192 "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query",
3193 "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows",
3194 ),
3195 ),
3196 )).length > 0;
3197
3198 const { snapshotSequence } = yield* getSnapshotSequence();3266 const { snapshotSequence } = yield* getSnapshotSequence();
3199 const watermarkRow = yield* getThreadEventWatermarkRow({3267 const watermarkRow = yield* getThreadEventWatermarkRow({
3200 threadId,3268 threadId,
...@@ -3253,6 +3321,7 @@ pending_approval_requests AS (...@@ -3253,6 +3321,7 @@ pending_approval_requests AS (
3253 getActiveProjectByWorkspaceRoot,3321 getActiveProjectByWorkspaceRoot,
3254 getProjectShellById,3322 getProjectShellById,
3255 getFirstActiveThreadIdByProjectId,3323 getFirstActiveThreadIdByProjectId,
3324 getImportedAgentSessionSources,
3256 getThreadCheckpointContext,3325 getThreadCheckpointContext,
3257 getFullThreadDiffContext,3326 getFullThreadDiffContext,
3258 getThreadShellById,3327 getThreadShellById,
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+10
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7 * @module ProjectionSnapshotQuery7 * @module ProjectionSnapshotQuery
8 */8 */
9import type {9import type {
10 AgentSessionImportSource,
10 ApprovalRequestId,11 ApprovalRequestId,
11 CheckpointRef,12 CheckpointRef,
12 OrchestrationCheckpointSummary,13 OrchestrationCheckpointSummary,
...@@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape {...@@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape {
170 projectId: ProjectId,171 projectId: ProjectId,
171 ) => Effect.Effect<Option.Option<ThreadId>, ProjectionRepositoryError>;172 ) => Effect.Effect<Option.Option<ThreadId>, ProjectionRepositoryError>;
172173
174 /** Read completed import sources without loading thread history. */
175 readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect<
176 ReadonlyArray<{
177 readonly threadId: ThreadId;
178 readonly source: AgentSessionImportSource;
179 }>,
180 ProjectionRepositoryError
181 >;
182
173 /**183 /**
174 * Read the checkpoint context needed to resolve a single thread diff.184 * Read the checkpoint context needed to resolve a single thread diff.
175 */185 */
apps/server/src/orchestration/decider.import.test.ts created+509
...@@ -0,0 +1,509 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import {
4 CommandId,
5 EventId,
6 MessageId,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as TestClock from "effect/testing/TestClock";
13
14import { decideOrchestrationCommand } from "./decider.ts";
15import { createEmptyReadModel, projectEvent } from "./projector.ts";
16
17it.layer(NodeServices.layer)("thread history import", (it) => {
18 it.effect("marks imported thread creation without changing live creation", () =>
19 Effect.gen(function* () {
20 const createdAt = "2026-08-24T10:00:00.000Z";
21 const projectId = ProjectId.make("project-1");
22 const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
23 sequence: 1,
24 eventId: EventId.make("event-project-created"),
25 aggregateKind: "project",
26 aggregateId: projectId,
27 type: "project.created",
28 occurredAt: createdAt,
29 commandId: CommandId.make("command-project-created"),
30 causationEventId: null,
31 correlationId: CommandId.make("command-project-created"),
32 metadata: {},
33 payload: {
34 projectId,
35 title: "Project",
36 workspaceRoot: "/tmp/project",
37 defaultModelSelection: null,
38 scripts: [],
39 createdAt,
40 updatedAt: createdAt,
41 },
42 });
43 const makeCreateCommand = (threadId: ThreadId) => ({
44 type: "thread.create" as const,
45 commandId: CommandId.make(`command-create-${threadId}`),
46 threadId,
47 projectId,
48 title: "Imported thread",
49 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
50 runtimeMode: "full-access" as const,
51 interactionMode: "default" as const,
52 branch: null,
53 worktreePath: null,
54 createdAt,
55 });
56
57 const imported = yield* decideOrchestrationCommand({
58 command: {
59 ...makeCreateCommand(ThreadId.make("import:codex:session-1")),
60 historyImport: true,
61 },
62 readModel,
63 });
64 const live = yield* decideOrchestrationCommand({
65 command: makeCreateCommand(ThreadId.make("live-thread")),
66 readModel,
67 });
68
69 expect(imported).toMatchObject({
70 type: "thread.created",
71 metadata: { historyImport: true },
72 });
73 expect(live).toMatchObject({ type: "thread.created" });
74 expect(live).not.toMatchObject({ metadata: { historyImport: true } });
75 }),
76 );
77
78 it.effect("settles imported messages at the latest absolute timestamp", () =>
79 Effect.gen(function* () {
80 const createdAt = "2026-08-24T10:30:00.000+02:00";
81 const threadId = ThreadId.make("import:codex:session-1");
82 const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
83 sequence: 1,
84 eventId: EventId.make("event-thread-created"),
85 aggregateKind: "thread",
86 aggregateId: threadId,
87 type: "thread.created",
88 occurredAt: createdAt,
89 commandId: CommandId.make("command-thread-created"),
90 causationEventId: null,
91 correlationId: CommandId.make("command-thread-created"),
92 metadata: {},
93 payload: {
94 threadId,
95 projectId: ProjectId.make("project-1"),
96 title: "Imported thread",
97 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
98 runtimeMode: "full-access",
99 interactionMode: "default",
100 branch: null,
101 worktreePath: null,
102 createdAt,
103 updatedAt: createdAt,
104 },
105 });
106
107 const events = yield* decideOrchestrationCommand({
108 command: {
109 type: "thread.history.import",
110 commandId: CommandId.make("command-import-history"),
111 threadId,
112 messages: [
113 {
114 messageId: MessageId.make(`${threadId}:000000`),
115 role: "user",
116 text: "Fix the bug",
117 createdAt,
118 },
119 {
120 messageId: MessageId.make(`${threadId}:000001`),
121 role: "assistant",
122 text: "Fixed",
123 createdAt: "2026-08-24T09:00:00.000Z",
124 },
125 ],
126 },
127 readModel,
128 });
129
130 expect(events).toMatchObject([
131 {
132 type: "thread.message-sent",
133 metadata: { historyImport: true },
134 payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false },
135 },
136 {
137 type: "thread.message-sent",
138 metadata: { historyImport: true },
139 payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false },
140 },
141 {
142 type: "thread.settled",
143 metadata: { historyImport: true },
144 occurredAt: "2026-08-24T09:00:00.000Z",
145 payload: {
146 settledAt: "2026-08-24T09:00:00.000Z",
147 updatedAt: "2026-08-24T09:00:00.000Z",
148 },
149 },
150 ]);
151
152 let projected = readModel;
153 const plannedEvents = Array.isArray(events) ? events : [events];
154 for (const [index, event] of plannedEvents.entries()) {
155 projected = yield* projectEvent(projected, { ...event, sequence: index + 2 });
156 }
157 projected = yield* projectEvent(projected, {
158 sequence: 5,
159 eventId: EventId.make("event-import-reverted"),
160 aggregateKind: "thread",
161 aggregateId: threadId,
162 type: "thread.reverted",
163 occurredAt: "2026-08-24T10:02:00.000Z",
164 commandId: CommandId.make("command-import-reverted"),
165 causationEventId: null,
166 correlationId: CommandId.make("command-import-reverted"),
167 metadata: {},
168 payload: { threadId, turnCount: 0 },
169 });
170 expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([
171 "Fix the bug",
172 "Fixed",
173 ]);
174 }),
175 );
176
177 it.effect("allows a thread with a newly imported user message to be settled", () =>
178 Effect.gen(function* () {
179 const createdAt = "2026-08-24T10:00:00.000Z";
180 yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z"));
181 const threadId = ThreadId.make("import:codex:session-1");
182 const withThread = yield* projectEvent(createEmptyReadModel(createdAt), {
183 sequence: 1,
184 eventId: EventId.make("event-import-thread-created"),
185 aggregateKind: "thread",
186 aggregateId: threadId,
187 type: "thread.created",
188 occurredAt: createdAt,
189 commandId: CommandId.make("command-import-thread-created"),
190 causationEventId: null,
191 correlationId: CommandId.make("command-import-thread-created"),
192 metadata: {},
193 payload: {
194 threadId,
195 projectId: ProjectId.make("project-1"),
196 title: "Imported thread",
197 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
198 runtimeMode: "full-access",
199 interactionMode: "default",
200 branch: null,
201 worktreePath: null,
202 createdAt,
203 updatedAt: createdAt,
204 },
205 });
206 const readModel = yield* projectEvent(withThread, {
207 sequence: 2,
208 eventId: EventId.make("event-import-user-message"),
209 aggregateKind: "thread",
210 aggregateId: threadId,
211 type: "thread.message-sent",
212 occurredAt: createdAt,
213 commandId: CommandId.make("command-import-user-message"),
214 causationEventId: null,
215 correlationId: CommandId.make("command-import-user-message"),
216 metadata: { historyImport: true },
217 payload: {
218 threadId,
219 messageId: MessageId.make("import:codex:session-1:0"),
220 role: "user",
221 text: "Existing prompt",
222 turnId: null,
223 streaming: false,
224 createdAt,
225 updatedAt: createdAt,
226 },
227 });
228
229 const result = yield* decideOrchestrationCommand({
230 command: {
231 type: "thread.settle",
232 commandId: CommandId.make("command-settle-imported-thread"),
233 threadId,
234 },
235 readModel,
236 });
237
238 expect(result).toMatchObject({ type: "thread.settled" });
239 }),
240 );
241
242 it.effect("rejects history import after a client message reaches the thread", () =>
243 Effect.gen(function* () {
244 const createdAt = "2026-08-24T10:00:00.000Z";
245 const liveMessageAt = "2026-08-24T10:02:00.000Z";
246 const threadId = ThreadId.make("import:codex:client-race");
247 const withThread = yield* projectEvent(createEmptyReadModel(createdAt), {
248 sequence: 1,
249 eventId: EventId.make("event-client-race-thread-created"),
250 aggregateKind: "thread",
251 aggregateId: threadId,
252 type: "thread.created",
253 occurredAt: createdAt,
254 commandId: CommandId.make("command-client-race-thread-created"),
255 causationEventId: null,
256 correlationId: CommandId.make("command-client-race-thread-created"),
257 metadata: {},
258 payload: {
259 threadId,
260 projectId: ProjectId.make("project-1"),
261 title: "Imported thread",
262 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
263 runtimeMode: "full-access",
264 interactionMode: "default",
265 branch: null,
266 worktreePath: null,
267 createdAt,
268 updatedAt: createdAt,
269 },
270 });
271 const readModel = yield* projectEvent(withThread, {
272 sequence: 2,
273 eventId: EventId.make("event-client-race-message"),
274 aggregateKind: "thread",
275 aggregateId: threadId,
276 type: "thread.message-sent",
277 occurredAt: liveMessageAt,
278 commandId: CommandId.make("command-client-race-message"),
279 causationEventId: null,
280 correlationId: CommandId.make("command-client-race-message"),
281 metadata: {},
282 payload: {
283 threadId,
284 messageId: MessageId.make("client-race-message"),
285 role: "user",
286 text: "Start live work",
287 turnId: null,
288 streaming: false,
289 createdAt: liveMessageAt,
290 updatedAt: liveMessageAt,
291 },
292 });
293
294 const error = yield* Effect.flip(
295 decideOrchestrationCommand({
296 command: {
297 type: "thread.history.import",
298 commandId: CommandId.make("command-client-race-import"),
299 threadId,
300 messages: [
301 {
302 messageId: MessageId.make(`${threadId}:000000`),
303 role: "user",
304 text: "Old work",
305 createdAt,
306 },
307 ],
308 },
309 readModel,
310 }),
311 );
312
313 expect(error._tag).toBe("OrchestrationCommandInvariantError");
314 expect(error.message).toContain("must be active and empty");
315 expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt);
316 }),
317 );
318
319 for (const requestKind of ["approval.requested", "user-input.requested"] as const) {
320 it.effect(`rejects history import with an open ${requestKind} activity`, () =>
321 Effect.gen(function* () {
322 const createdAt = "2026-08-24T10:00:00.000Z";
323 const threadId = ThreadId.make(`import:codex:${requestKind}`);
324 const withThread = yield* projectEvent(createEmptyReadModel(createdAt), {
325 sequence: 1,
326 eventId: EventId.make(`event-${requestKind}-thread-created`),
327 aggregateKind: "thread",
328 aggregateId: threadId,
329 type: "thread.created",
330 occurredAt: createdAt,
331 commandId: CommandId.make(`command-${requestKind}-thread-created`),
332 causationEventId: null,
333 correlationId: CommandId.make(`command-${requestKind}-thread-created`),
334 metadata: {},
335 payload: {
336 threadId,
337 projectId: ProjectId.make("project-1"),
338 title: "Imported thread",
339 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
340 runtimeMode: "full-access",
341 interactionMode: "default",
342 branch: null,
343 worktreePath: null,
344 createdAt,
345 updatedAt: createdAt,
346 },
347 });
348 const readModel = yield* projectEvent(withThread, {
349 sequence: 2,
350 eventId: EventId.make(`event-${requestKind}`),
351 aggregateKind: "thread",
352 aggregateId: threadId,
353 type: "thread.activity-appended",
354 occurredAt: createdAt,
355 commandId: CommandId.make(`command-${requestKind}`),
356 causationEventId: null,
357 correlationId: CommandId.make(`command-${requestKind}`),
358 metadata: {},
359 payload: {
360 threadId,
361 activity: {
362 id: EventId.make(`activity-${requestKind}`),
363 tone: "approval",
364 kind: requestKind,
365 summary: "Pending request",
366 payload: { requestId: "request-1" },
367 turnId: null,
368 createdAt,
369 },
370 },
371 });
372
373 const error = yield* Effect.flip(
374 decideOrchestrationCommand({
375 command: {
376 type: "thread.history.import",
377 commandId: CommandId.make(`command-import-${requestKind}`),
378 threadId,
379 messages: [
380 {
381 messageId: MessageId.make(`${threadId}:000000`),
382 role: "user",
383 text: "Old work",
384 createdAt,
385 },
386 ],
387 },
388 readModel,
389 }),
390 );
391
392 expect(error._tag).toBe("OrchestrationCommandInvariantError");
393 expect(error.message).toContain("must be active and empty");
394 }),
395 );
396 }
397
398 it.effect("rejects a live user message in the imported-session namespace", () =>
399 Effect.gen(function* () {
400 const createdAt = "2026-08-24T10:00:00.000Z";
401 const threadId = ThreadId.make("thread-live-message");
402 const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
403 sequence: 1,
404 eventId: EventId.make("event-live-thread-created"),
405 aggregateKind: "thread",
406 aggregateId: threadId,
407 type: "thread.created",
408 occurredAt: createdAt,
409 commandId: CommandId.make("command-live-thread-created"),
410 causationEventId: null,
411 correlationId: CommandId.make("command-live-thread-created"),
412 metadata: {},
413 payload: {
414 threadId,
415 projectId: ProjectId.make("project-1"),
416 title: "Live thread",
417 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
418 runtimeMode: "full-access",
419 interactionMode: "default",
420 branch: null,
421 worktreePath: null,
422 createdAt,
423 updatedAt: createdAt,
424 },
425 });
426
427 const error = yield* Effect.flip(
428 decideOrchestrationCommand({
429 command: {
430 type: "thread.turn.start",
431 commandId: CommandId.make("command-live-import-id"),
432 threadId,
433 message: {
434 messageId: MessageId.make("import:forged-live-message"),
435 role: "user",
436 text: "Live work",
437 attachments: [],
438 },
439 runtimeMode: "full-access",
440 interactionMode: "default",
441 createdAt,
442 },
443 readModel,
444 }),
445 );
446
447 expect(error._tag).toBe("OrchestrationCommandInvariantError");
448 expect(error.message).toContain("reserved imported-session namespace");
449 }),
450 );
451
452 it.effect("rejects live assistant messages in the imported-session namespace", () =>
453 Effect.gen(function* () {
454 const createdAt = "2026-08-24T10:00:00.000Z";
455 const threadId = ThreadId.make("thread-live-assistant-message");
456 const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
457 sequence: 1,
458 eventId: EventId.make("event-live-assistant-thread-created"),
459 aggregateKind: "thread",
460 aggregateId: threadId,
461 type: "thread.created",
462 occurredAt: createdAt,
463 commandId: CommandId.make("command-live-assistant-thread-created"),
464 causationEventId: null,
465 correlationId: CommandId.make("command-live-assistant-thread-created"),
466 metadata: {},
467 payload: {
468 threadId,
469 projectId: ProjectId.make("project-1"),
470 title: "Live thread",
471 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
472 runtimeMode: "full-access",
473 interactionMode: "default",
474 branch: null,
475 worktreePath: null,
476 createdAt,
477 updatedAt: createdAt,
478 },
479 });
480
481 for (const commandType of [
482 "thread.message.assistant.delta",
483 "thread.message.assistant.complete",
484 ] as const) {
485 const command =
486 commandType === "thread.message.assistant.delta"
487 ? {
488 type: commandType,
489 commandId: CommandId.make("command-live-assistant-delta-import-id"),
490 threadId,
491 messageId: MessageId.make("import:forged-live-assistant-message"),
492 delta: "Live work",
493 createdAt,
494 }
495 : {
496 type: commandType,
497 commandId: CommandId.make("command-live-assistant-complete-import-id"),
498 threadId,
499 messageId: MessageId.make("import:forged-live-assistant-message"),
500 createdAt,
501 };
502 const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel }));
503
504 expect(error._tag).toBe("OrchestrationCommandInvariantError");
505 expect(error.message).toContain("reserved imported-session namespace");
506 }
507 }),
508 );
509});
apps/server/src/orchestration/decider.ts+95-1
...@@ -2,12 +2,14 @@ import {...@@ -2,12 +2,14 @@ import {
2 EventId,2 EventId,
3 MessageId,3 MessageId,
4 UserInputRequestedPayload,4 UserInputRequestedPayload,
5 isImportedAgentSessionMessageId,
5 type OrchestrationCommand,6 type OrchestrationCommand,
6 type OrchestrationEvent,7 type OrchestrationEvent,
7 type OrchestrationReadModel,8 type OrchestrationReadModel,
8 type OrchestrationThread,9 type OrchestrationThread,
9 type OrchestrationThreadActivity,10 type OrchestrationThreadActivity,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
11import * as DateTime from "effect/DateTime";13import * as DateTime from "effect/DateTime";
12import * as Crypto from "effect/Crypto";14import * as Crypto from "effect/Crypto";
13import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
...@@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread(...@@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread(
98 let latestUserMessageAt: string | null = null;100 let latestUserMessageAt: string | null = null;
99 let latestUserMessageAtMs = Number.NEGATIVE_INFINITY;101 let latestUserMessageAtMs = Number.NEGATIVE_INFINITY;
100 for (const message of thread.messages) {102 for (const message of thread.messages) {
101 if (message.role !== "user") continue;103 if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue;
102 const messageAtMs = Date.parse(message.createdAt);104 const messageAtMs = Date.parse(message.createdAt);
103 latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs);105 latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs);
104 if (messageAtMs === latestUserMessageAtMs) {106 if (messageAtMs === latestUserMessageAtMs) {
...@@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
347 aggregateId: command.threadId,349 aggregateId: command.threadId,
348 occurredAt: command.createdAt,350 occurredAt: command.createdAt,
349 commandId: command.commandId,351 commandId: command.commandId,
352 ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}),
350 })),353 })),
351 type: "thread.created",354 type: "thread.created",
352 payload: {355 payload: {
...@@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
905 }908 }
906909
907 case "thread.turn.start": {910 case "thread.turn.start": {
911 if (isImportedAgentSessionMessageId(command.message.messageId)) {
912 return yield* new OrchestrationCommandInvariantError({
913 commandType: command.type,
914 detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`,
915 });
916 }
908 const targetThread = yield* requireThread({917 const targetThread = yield* requireThread({
909 readModel,918 readModel,
910 command,919 command,
...@@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1272 }1281 }
12731282
1274 case "thread.message.assistant.delta": {1283 case "thread.message.assistant.delta": {
1284 if (isImportedAgentSessionMessageId(command.messageId)) {
1285 return yield* new OrchestrationCommandInvariantError({
1286 commandType: command.type,
1287 detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`,
1288 });
1289 }
1275 yield* requireThread({1290 yield* requireThread({
1276 readModel,1291 readModel,
1277 command,1292 command,
...@@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1299 }1314 }
13001315
1301 case "thread.message.assistant.complete": {1316 case "thread.message.assistant.complete": {
1317 if (isImportedAgentSessionMessageId(command.messageId)) {
1318 return yield* new OrchestrationCommandInvariantError({
1319 commandType: command.type,
1320 detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`,
1321 });
1322 }
1302 yield* requireThread({1323 yield* requireThread({
1303 readModel,1324 readModel,
1304 command,1325 command,
...@@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1325 };1346 };
1326 }1347 }
13271348
1349 case "thread.history.import": {
1350 const thread = yield* requireThread({
1351 readModel,
1352 command,
1353 threadId: command.threadId,
1354 });
1355 if (
1356 thread.deletedAt !== null ||
1357 thread.archivedAt !== null ||
1358 thread.messages.length > 0 ||
1359 thread.latestTurn !== null ||
1360 thread.session !== null ||
1361 hasOpenBlockingRequest(thread)
1362 ) {
1363 return yield* new OrchestrationCommandInvariantError({
1364 commandType: command.type,
1365 detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`,
1366 });
1367 }
1368 const firstMessage = command.messages[0];
1369 if (firstMessage === undefined) {
1370 return yield* new OrchestrationCommandInvariantError({
1371 commandType: command.type,
1372 detail: "Thread history imports require at least one message.",
1373 });
1374 }
1375
1376 const events: Array<PlannedOrchestrationEvent> = [];
1377 for (const message of command.messages) {
1378 events.push({
1379 ...(yield* withEventBase({
1380 aggregateKind: "thread",
1381 aggregateId: command.threadId,
1382 occurredAt: message.createdAt,
1383 commandId: command.commandId,
1384 metadata: { historyImport: true },
1385 })),
1386 type: "thread.message-sent",
1387 payload: {
1388 threadId: command.threadId,
1389 messageId: message.messageId,
1390 role: message.role,
1391 text: message.text,
1392 turnId: null,
1393 streaming: false,
1394 createdAt: message.createdAt,
1395 updatedAt: message.createdAt,
1396 },
1397 });
1398 }
1399 const settledAt = command.messages.reduce(
1400 (latest, message) =>
1401 compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest,
1402 firstMessage.createdAt,
1403 );
1404 events.push({
1405 ...(yield* withEventBase({
1406 aggregateKind: "thread",
1407 aggregateId: command.threadId,
1408 occurredAt: settledAt,
1409 commandId: command.commandId,
1410 metadata: { historyImport: true },
1411 })),
1412 type: "thread.settled",
1413 payload: {
1414 threadId: command.threadId,
1415 settledAt,
1416 updatedAt: settledAt,
1417 },
1418 });
1419 return events;
1420 }
1421
1328 case "thread.proposed-plan.upsert": {1422 case "thread.proposed-plan.upsert": {
1329 yield* requireThread({1423 yield* requireThread({
1330 readModel,1424 readModel,
apps/server/src/orchestration/projector.ts+15-5
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";1import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";
2import {2import {
3 isImportedAgentSessionMessageId,
3 OrchestrationCheckpointSummary,4 OrchestrationCheckpointSummary,
4 OrchestrationMessage,5 OrchestrationMessage,
5 OrchestrationSession,6 OrchestrationSession,
6 OrchestrationThread,7 OrchestrationThread,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
10import * as Predicate from "effect/Predicate";12import * as Predicate from "effect/Predicate";
...@@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert(...@@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert(
117): ReadonlyArray<OrchestrationMessage> {119): ReadonlyArray<OrchestrationMessage> {
118 const retainedMessageIds = new Set<string>();120 const retainedMessageIds = new Set<string>();
119 for (const message of messages) {121 for (const message of messages) {
120 if (message.role === "system") {122 if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) {
121 retainedMessageIds.add(message.id);123 retainedMessageIds.add(message.id);
122 continue;124 continue;
123 }125 }
...@@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert(...@@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert(
127 }129 }
128130
129 const retainedUserCount = messages.filter(131 const retainedUserCount = messages.filter(
130 (message) => message.role === "user" && retainedMessageIds.has(message.id),132 (message) =>
133 message.role === "user" &&
134 !isImportedAgentSessionMessageId(message.id) &&
135 retainedMessageIds.has(message.id),
131 ).length;136 ).length;
132 const missingUserCount = Math.max(0, turnCount - retainedUserCount);137 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
133 if (missingUserCount > 0) {138 if (missingUserCount > 0) {
...@@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert(...@@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert(
140 )145 )
141 .toSorted(146 .toSorted(
142 (left, right) =>147 (left, right) =>
143 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),148 compareDateTimeStrings(left.createdAt, right.createdAt) ||
149 left.id.localeCompare(right.id),
144 )150 )
145 .slice(0, missingUserCount);151 .slice(0, missingUserCount);
146 for (const message of fallbackUserMessages) {152 for (const message of fallbackUserMessages) {
...@@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert(...@@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert(
149 }155 }
150156
151 const retainedAssistantCount = messages.filter(157 const retainedAssistantCount = messages.filter(
152 (message) => message.role === "assistant" && retainedMessageIds.has(message.id),158 (message) =>
159 message.role === "assistant" &&
160 !isImportedAgentSessionMessageId(message.id) &&
161 retainedMessageIds.has(message.id),
153 ).length;162 ).length;
154 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);163 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
155 if (missingAssistantCount > 0) {164 if (missingAssistantCount > 0) {
...@@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert(...@@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert(
162 )171 )
163 .toSorted(172 .toSorted(
164 (left, right) =>173 (left, right) =>
165 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),174 compareDateTimeStrings(left.createdAt, right.createdAt) ||
175 left.id.localeCompare(right.id),
166 )176 )
167 .slice(0, missingAssistantCount);177 .slice(0, missingAssistantCount);
168 for (const message of fallbackAssistantMessages) {178 for (const message of fallbackAssistantMessages) {
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+13-1
...@@ -12,12 +12,24 @@ const layer = it.layer(...@@ -12,12 +12,24 @@ const layer = it.layer(
12);12);
1313
14layer("ProjectionThreadMessageRepository", (it) => {14layer("ProjectionThreadMessageRepository", (it) => {
15 it.effect("finds the latest user-message time within one thread", () =>15 it.effect("finds the latest live user-message time within one thread", () =>
16 Effect.gen(function* () {16 Effect.gen(function* () {
17 const repository = yield* ProjectionThreadMessageRepository;17 const repository = yield* ProjectionThreadMessageRepository;
18 const threadId = ThreadId.make("thread-latest-user-message");18 const threadId = ThreadId.make("thread-latest-user-message");
19 assert.isNull(yield* repository.getLatestUserMessageAt({ threadId }));19 assert.isNull(yield* repository.getLatestUserMessageAt({ threadId }));
2020
21 yield* repository.upsert({
22 messageId: MessageId.make("import:codex:latest-user-message:000000"),
23 threadId,
24 turnId: null,
25 role: "user",
26 text: "Imported prompt",
27 isStreaming: false,
28 createdAt: "2026-02-28T19:05:06.000Z",
29 updatedAt: "2026-02-28T19:05:06.000Z",
30 });
31 assert.isNull(yield* repository.getLatestUserMessageAt({ threadId }));
32
21 const messages = [33 const messages = [
22 { role: "user", createdAt: "2026-02-28T19:05:02.000Z" },34 { role: "user", createdAt: "2026-02-28T19:05:02.000Z" },
23 { role: "user", createdAt: "2026-02-28T19:05:01.000Z" },35 { role: "user", createdAt: "2026-02-28T19:05:01.000Z" },
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+1
...@@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
191 SELECT MAX(created_at) AS "latestUserMessageAt"191 SELECT MAX(created_at) AS "latestUserMessageAt"
192 FROM projection_thread_messages192 FROM projection_thread_messages
193 WHERE thread_id = ${threadId} AND role = 'user'193 WHERE thread_id = ${threadId} AND role = 'user'
194 AND message_id NOT GLOB 'import:*'
194 `,195 `,
195 });196 });
196197
apps/server/src/persistence/ProviderSessionRuntime.ts+146-5
...@@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
10import * as SqlSchema from "effect/unstable/sql/SqlSchema";10import * as SqlSchema from "effect/unstable/sql/SqlSchema";
1111
12import {12import {
13 AgentSessionImportSource,
13 IsoDateTime,14 IsoDateTime,
14 ProviderInstanceId,15 ProviderInstanceId,
15 ProviderSessionRuntimeStatus,16 ProviderSessionRuntimeStatus,
...@@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp...@@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp
58export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });59export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
59export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;60export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;
6061
62export const RecordImportedTranscriptInput = Schema.Struct({
63 threadId: ThreadId,
64 source: AgentSessionImportSource,
65});
66export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type;
67
68export interface ProviderSessionRuntimeUpsertOptions {
69 readonly onConflict?: "update" | "ignore";
70}
71
61/**72/**
62 * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence.73 * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence.
63 */74 */
...@@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service<...@@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service<
67 /**78 /**
68 * Insert or replace a provider runtime row.79 * Insert or replace a provider runtime row.
69 *80 *
70 * Upserts by canonical `threadId`, including JSON payload/cursor fields.81 * Upserts by canonical `threadId`, retaining imported transcript records
82 * from the current database row.
71 */83 */
72 readonly upsert: (84 readonly upsert: (
73 runtime: ProviderSessionRuntime,85 runtime: ProviderSessionRuntime,
86 options?: ProviderSessionRuntimeUpsertOptions,
87 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
88
89 /** Record one source file without replacing the current session state. */
90 readonly recordImportedTranscript: (
91 input: RecordImportedTranscriptInput,
74 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;92 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
7593
76 /**94 /**
...@@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({...@@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({
129147
130const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;148const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
131149
150const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields(
151 Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }),
152);
153
132function toPersistenceSqlOrDecodeError(154function toPersistenceSqlOrDecodeError(
133 sqlOperation: string,155 sqlOperation: string,
134 decodeOperation: string,156 decodeOperation: string,
...@@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError(...@@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError(
147export const make = Effect.gen(function* () {169export const make = Effect.gen(function* () {
148 const sql = yield* SqlClient.SqlClient;170 const sql = yield* SqlClient.SqlClient;
149171
172 // Runtime writes can carry stale payloads. Only recordImportedTranscript may
173 // change source records, so restore that field from the row being updated.
150 const upsertRuntimeRow = SqlSchema.void({174 const upsertRuntimeRow = SqlSchema.void({
151 Request: ProviderSessionRuntimeDbRowSchema,175 Request: ProviderSessionRuntimeDbRowSchema,
152 execute: (runtime) =>176 execute: (runtime) =>
...@@ -171,7 +195,11 @@ export const make = Effect.gen(function* () {...@@ -171,7 +195,11 @@ export const make = Effect.gen(function* () {
171 ${runtime.status},195 ${runtime.status},
172 ${runtime.lastSeenAt},196 ${runtime.lastSeenAt},
173 ${runtime.resumeCursor},197 ${runtime.resumeCursor},
174 ${runtime.runtimePayload}198 CASE
199 WHEN json_type(${runtime.runtimePayload}) = 'object'
200 THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts')
201 ELSE ${runtime.runtimePayload}
202 END
175 )203 )
176 ON CONFLICT (thread_id)204 ON CONFLICT (thread_id)
177 DO UPDATE SET205 DO UPDATE SET
...@@ -182,7 +210,107 @@ export const make = Effect.gen(function* () {...@@ -182,7 +210,107 @@ export const make = Effect.gen(function* () {
182 status = excluded.status,210 status = excluded.status,
183 last_seen_at = excluded.last_seen_at,211 last_seen_at = excluded.last_seen_at,
184 resume_cursor_json = excluded.resume_cursor_json,212 resume_cursor_json = excluded.resume_cursor_json,
185 runtime_payload_json = excluded.runtime_payload_json213 runtime_payload_json = CASE
214 WHEN json_type(
215 CASE
216 WHEN json_valid(provider_session_runtime.runtime_payload_json)
217 THEN provider_session_runtime.runtime_payload_json
218 ELSE '{}'
219 END,
220 '$.importedTranscripts'
221 ) IS NOT NULL
222 THEN json_set(
223 CASE
224 WHEN json_type(excluded.runtime_payload_json) = 'object'
225 THEN excluded.runtime_payload_json
226 ELSE '{}'
227 END,
228 '$.importedTranscripts',
229 json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts')
230 )
231 ELSE excluded.runtime_payload_json
232 END
233 `,
234 });
235
236 const insertRuntimeRow = SqlSchema.void({
237 Request: ProviderSessionRuntimeDbRowSchema,
238 execute: (runtime) =>
239 sql`
240 INSERT INTO provider_session_runtime (
241 thread_id,
242 provider_name,
243 provider_instance_id,
244 adapter_key,
245 runtime_mode,
246 status,
247 last_seen_at,
248 resume_cursor_json,
249 runtime_payload_json
250 )
251 VALUES (
252 ${runtime.threadId},
253 ${runtime.providerName},
254 ${runtime.providerInstanceId},
255 ${runtime.adapterKey},
256 ${runtime.runtimeMode},
257 ${runtime.status},
258 ${runtime.lastSeenAt},
259 ${runtime.resumeCursor},
260 CASE
261 WHEN json_type(${runtime.runtimePayload}) = 'object'
262 THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts')
263 ELSE ${runtime.runtimePayload}
264 END
265 )
266 ON CONFLICT (thread_id) DO NOTHING
267 `,
268 });
269
270 const recordImportedTranscriptRow = SqlSchema.void({
271 Request: RecordImportedTranscriptRequestSchema,
272 execute: ({ threadId, source }) =>
273 sql`
274 WITH current_runtime AS (
275 SELECT CASE
276 WHEN json_valid(runtime_payload_json) THEN CASE
277 WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json
278 ELSE '{}'
279 END
280 ELSE '{}'
281 END AS payload
282 FROM provider_session_runtime
283 WHERE thread_id = ${threadId}
284 )
285 UPDATE provider_session_runtime
286 SET runtime_payload_json = (
287 SELECT json_set(
288 payload,
289 '$.importedTranscripts',
290 json((
291 SELECT json_group_array(json(value))
292 FROM (
293 SELECT value
294 FROM json_each(CASE
295 WHEN json_type(payload, '$.importedTranscripts') = 'array'
296 THEN json_extract(payload, '$.importedTranscripts')
297 ELSE '[]'
298 END)
299 WHERE CASE
300 WHEN type = 'object' THEN
301 json_extract(value, '$.providerInstanceId')
302 IS NOT json_extract(${source}, '$.providerInstanceId')
303 OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath')
304 ELSE 0
305 END
306 UNION ALL
307 SELECT ${source} AS value
308 )
309 ))
310 )
311 FROM current_runtime
312 )
313 WHERE thread_id = ${threadId}
186 `,314 `,
187 });315 });
188316
...@@ -235,8 +363,8 @@ export const make = Effect.gen(function* () {...@@ -235,8 +363,8 @@ export const make = Effect.gen(function* () {
235 `,363 `,
236 });364 });
237365
238 const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) =>366 const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) =>
239 upsertRuntimeRow(runtime).pipe(367 (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe(
240 Effect.mapError(368 Effect.mapError(
241 toPersistenceSqlOrDecodeError(369 toPersistenceSqlOrDecodeError(
242 "ProviderSessionRuntimeRepository.upsert:query",370 "ProviderSessionRuntimeRepository.upsert:query",
...@@ -246,6 +374,18 @@ export const make = Effect.gen(function* () {...@@ -246,6 +374,18 @@ export const make = Effect.gen(function* () {
246 ),374 ),
247 );375 );
248376
377 const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] =
378 (input) =>
379 recordImportedTranscriptRow(input).pipe(
380 Effect.mapError(
381 toPersistenceSqlOrDecodeError(
382 "ProviderSessionRuntimeRepository.recordImportedTranscript:query",
383 "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest",
384 { threadId: input.threadId },
385 ),
386 ),
387 );
388
249 const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) =>389 const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) =>
250 getRuntimeRowByThreadId(input).pipe(390 getRuntimeRowByThreadId(input).pipe(
251 Effect.mapError(391 Effect.mapError(
...@@ -324,6 +464,7 @@ export const make = Effect.gen(function* () {...@@ -324,6 +464,7 @@ export const make = Effect.gen(function* () {
324464
325 return {465 return {
326 upsert,466 upsert,
467 recordImportedTranscript,
327 getByThreadId,468 getByThreadId,
328 list,469 list,
329 deleteByThreadId,470 deleteByThreadId,
apps/server/src/project/AgentSessionImporter.test.ts created+1213
...@@ -0,0 +1,1213 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it, vi } from "@effect/vitest";
3import {
4 AgentSessionImportProjectChangedError,
5 CommandId,
6 MessageId,
7 ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 ThreadId,
11 type OrchestrationCommand,
12 type OrchestrationProjectShell,
13 type OrchestrationThread,
14 type ProviderSendTurnInput,
15} from "@t3tools/contracts";
16import * as Effect from "effect/Effect";
17import * as Deferred from "effect/Deferred";
18import * as Fiber from "effect/Fiber";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24import * as Stream from "effect/Stream";
25import * as TestClock from "effect/testing/TestClock";
26
27import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts";
28import { ServerConfig } from "../config.ts";
29import { GitWorkflowService } from "../git/GitWorkflowService.ts";
30import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts";
31import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
32import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
33import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";
34import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts";
35import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts";
36import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts";
37import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts";
38import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
39import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts";
40import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts";
41import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
42import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
43import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts";
44import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts";
45import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts";
46import {
47 NoOpProviderEventLoggers,
48 ProviderEventLoggers,
49} from "../provider/Layers/ProviderEventLoggers.ts";
50import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts";
51import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts";
52import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts";
53import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts";
54import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts";
55import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
56import { ServerSettingsService } from "../serverSettings.ts";
57import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
58import { TextGeneration } from "../textGeneration/TextGeneration.ts";
59import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts";
60import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
61import { importRecentAgentThreads } from "./AgentSessionImporter.ts";
62import * as AgentSessionScanner from "./AgentSessionScanner.ts";
63
64const PROJECT_ID = ProjectId.make("project-1");
65const WORKSPACE_ROOT = "/tmp/project-from-server";
66const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000";
67const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
68
69const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({
70 source,
71 providerInstanceId: ProviderInstanceId.make(source),
72 providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID,
73 title: `Imported ${source} thread`,
74 model: null,
75 createdAt: "2026-08-24T10:00:00.000Z",
76 updatedAt: "2026-08-24T10:01:00.000Z",
77 messages: [
78 { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" },
79 { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" },
80 ],
81});
82
83const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) =>
84 ({
85 _tag: "Importable",
86 thread,
87 source: {
88 provider: thread.source,
89 providerInstanceId: thread.providerInstanceId,
90 providerSessionId: thread.providerSessionId,
91 filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`,
92 size: 0,
93 mtimeMs: 0,
94 device: 0,
95 inode: 0,
96 birthtimeMs: 0,
97 },
98 }) satisfies AgentSessionScanner.AgentSessionRecentThread;
99
100const makeProject = (): OrchestrationProjectShell => ({
101 id: PROJECT_ID,
102 title: "Project",
103 workspaceRoot: WORKSPACE_ROOT,
104 defaultModelSelection: null,
105 scripts: [],
106 createdAt: "2026-08-24T09:00:00.000Z",
107 updatedAt: "2026-08-24T09:00:00.000Z",
108});
109
110const makeProjectedThread = (input: {
111 readonly source: "codex" | "claudeAgent";
112 readonly projectId?: ProjectId;
113 readonly imported?: boolean;
114 readonly includeFollowup?: boolean;
115}): OrchestrationThread => {
116 const sourceThread = makeThread(input.source);
117 const threadId = ThreadId.make(
118 `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`,
119 );
120 return {
121 id: threadId,
122 projectId: input.projectId ?? PROJECT_ID,
123 title: sourceThread.title,
124 modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" },
125 runtimeMode: "full-access",
126 interactionMode: "default",
127 branch: null,
128 worktreePath: null,
129 latestTurn: null,
130 createdAt: sourceThread.createdAt,
131 updatedAt: sourceThread.updatedAt,
132 archivedAt: null,
133 settledOverride: null,
134 settledAt: null,
135 deletedAt: null,
136 messages: input.imported
137 ? [
138 {
139 id: MessageId.make(`${threadId}:000000`),
140 role: "user",
141 text: "Fix the bug",
142 turnId: null,
143 streaming: false,
144 createdAt: "2026-08-24T10:00:00.000Z",
145 updatedAt: "2026-08-24T10:00:00.000Z",
146 },
147 ...(input.includeFollowup
148 ? [
149 {
150 id: MessageId.make("user-followup"),
151 role: "user" as const,
152 text: "Keep going",
153 turnId: null,
154 streaming: false,
155 createdAt: "2026-08-24T10:02:00.000Z",
156 updatedAt: "2026-08-24T10:02:00.000Z",
157 },
158 ]
159 : []),
160 ]
161 : [],
162 proposedPlans: [],
163 activities: [],
164 checkpoints: [],
165 session: null,
166 };
167};
168
169const makeSnapshotsLayer = (input: {
170 readonly project?: OrchestrationProjectShell;
171 readonly getThread?: (threadId: ThreadId) => Option.Option<OrchestrationThread>;
172}) =>
173 Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({
174 getProjectShellById: () =>
175 Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)),
176 getImportedAgentSessionSources: () => Effect.succeed([]),
177 getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()),
178 });
179
180const runImport = (input: {
181 readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"];
182 readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"];
183 readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
184 readonly snapshots: ReturnType<typeof makeSnapshotsLayer>;
185 readonly expectedWorkspaceRoot?: string;
186}) =>
187 importRecentAgentThreads({
188 projectId: PROJECT_ID,
189 ...(input.expectedWorkspaceRoot === undefined
190 ? {}
191 : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }),
192 }).pipe(
193 Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner),
194 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine),
195 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory),
196 Effect.provide(input.snapshots),
197 );
198
199it.layer(NodeServices.layer)("AgentSessionImporter", (it) => {
200 describe("importRecentAgentThreads", () => {
201 it.effect("uses the project root and stores provider-specific resume cursors", () =>
202 Effect.gen(function* () {
203 const commands: Array<OrchestrationCommand> = [];
204 const bindings: Array<ProviderSessionDirectory.ProviderRuntimeBinding> = [];
205 let scannedRoot: string | undefined;
206 const scanner = AgentSessionScanner.AgentSessionScanner.of({
207 scan: Effect.die("unused"),
208 recentThreads: (workspaceRoot) => {
209 scannedRoot = workspaceRoot;
210 return Stream.concat(
211 Stream.succeed(makeThreadOutcome(makeThread("codex"))),
212 Stream.fromEffect(
213 Effect.sync(() => {
214 expect(bindings).toHaveLength(1);
215 return makeThreadOutcome(makeThread("claudeAgent"));
216 }),
217 ),
218 );
219 },
220 });
221 const engine = OrchestrationEngine.OrchestrationEngineService.of({
222 dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })),
223 readEvents: () => Stream.empty,
224 readThreadEvents: () => Stream.empty,
225 getThreadReplayStats: () => Effect.die("unused"),
226 streamDomainEvents: Stream.empty,
227 subscribeDomainEvents: Effect.succeed(Stream.empty),
228 latestSequence: Effect.succeed(0),
229 });
230 const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
231 upsert: (binding) => Effect.sync(() => void bindings.push(binding)),
232 getProvider: () => Effect.die("unused"),
233 recordImportedTranscript: () => Effect.void,
234 getBinding: () => Effect.succeed(Option.none()),
235 listThreadIds: () => Effect.die("unused"),
236 listBindings: () => Effect.die("unused"),
237 });
238
239 const result = yield* runImport({
240 scanner,
241 engine,
242 directory,
243 snapshots: makeSnapshotsLayer({ project: makeProject() }),
244 expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`,
245 });
246
247 expect(result).toEqual({ importedCount: 2, skippedCount: 0 });
248 expect(scannedRoot).toBe(WORKSPACE_ROOT);
249 expect(commands.map((command) => command.type)).toEqual([
250 "thread.create",
251 "thread.history.import",
252 "thread.create",
253 "thread.history.import",
254 ]);
255 expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([
256 { historyImport: true },
257 { historyImport: true },
258 ]);
259 expect(
260 commands
261 .filter((command) => command.type === "thread.history.import")
262 .flatMap((command) => command.messages.map((message) => message.messageId)),
263 ).toEqual([
264 "import:codex:codex-session:000000",
265 "import:codex:codex-session:000001",
266 `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`,
267 `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`,
268 ]);
269 expect(bindings).toMatchObject([
270 {
271 provider: "codex",
272 providerInstanceId: "codex",
273 resumeCursor: { threadId: "codex-session" },
274 runtimePayload: { cwd: WORKSPACE_ROOT },
275 },
276 {
277 provider: "claudeAgent",
278 providerInstanceId: "claudeAgent",
279 resumeCursor: {
280 threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`,
281 resume: CLAUDE_SESSION_ID,
282 },
283 runtimePayload: { cwd: WORKSPACE_ROOT },
284 },
285 ]);
286 }),
287 );
288
289 it.effect("rejects a changed project root before scanning or writing", () =>
290 Effect.gen(function* () {
291 const recentThreads = vi.fn(() => Stream.empty);
292 const error = yield* importRecentAgentThreads({
293 projectId: PROJECT_ID,
294 expectedWorkspaceRoot: WORKSPACE_ROOT,
295 }).pipe(
296 Effect.provideService(
297 AgentSessionScanner.AgentSessionScanner,
298 AgentSessionScanner.AgentSessionScanner.of({
299 scan: Effect.die("must not scan a changed project"),
300 recentThreads,
301 }),
302 ),
303 Effect.provide(
304 Layer.mergeAll(
305 Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}),
306 Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}),
307 makeSnapshotsLayer({
308 project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" },
309 }),
310 ),
311 ),
312 Effect.flip,
313 );
314
315 expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID }));
316 expect(recentThreads).not.toHaveBeenCalled();
317 }),
318 );
319
320 it.effect("counts scanner skips without writing a thread or binding", () =>
321 Effect.gen(function* () {
322 const scanner = AgentSessionScanner.AgentSessionScanner.of({
323 scan: Effect.die("unused"),
324 recentThreads: () => Stream.succeed({ _tag: "Skipped" }),
325 });
326 const engine = OrchestrationEngine.OrchestrationEngineService.of({
327 dispatch: () => Effect.die("must not dispatch for a scanner skip"),
328 readEvents: () => Stream.empty,
329 readThreadEvents: () => Stream.empty,
330 getThreadReplayStats: () => Effect.die("unused"),
331 streamDomainEvents: Stream.empty,
332 subscribeDomainEvents: Effect.succeed(Stream.empty),
333 latestSequence: Effect.succeed(0),
334 });
335 const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
336 upsert: () => Effect.die("must not bind a scanner skip"),
337 getProvider: () => Effect.die("unused"),
338 recordImportedTranscript: () => Effect.die("unused"),
339 getBinding: () => Effect.die("must not read a scanner skip binding"),
340 listThreadIds: () => Effect.die("unused"),
341 listBindings: () => Effect.die("unused"),
342 });
343
344 const result = yield* runImport({
345 scanner,
346 engine,
347 directory,
348 snapshots: makeSnapshotsLayer({ project: makeProject() }),
349 });
350
351 expect(result).toEqual({ importedCount: 0, skippedCount: 1 });
352 }),
353 );
354
355 it.effect("recovers after a rejected history receipt and a failed binding write", () =>
356 Effect.gen(function* () {
357 let threadCreated = false;
358 let historyImported = false;
359 let historyAttemptCount = 0;
360 let bindingAttemptCount = 0;
361 const rejectedCommandIds = new Set<string>();
362 const bindings: Array<ProviderSessionDirectory.ProviderRuntimeBinding> = [];
363 const scanner = AgentSessionScanner.AgentSessionScanner.of({
364 scan: Effect.die("unused"),
365 recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]),
366 });
367 const engine = OrchestrationEngine.OrchestrationEngineService.of({
368 dispatch: (command) => {
369 if (rejectedCommandIds.has(command.commandId)) {
370 return Effect.fail(
371 new OrchestrationCommandInvariantError({
372 commandType: command.type,
373 detail: "Previously rejected.",
374 }),
375 );
376 }
377 if (command.type === "thread.create") threadCreated = true;
378 if (command.type === "thread.history.import") {
379 historyAttemptCount += 1;
380 if (historyAttemptCount === 1) {
381 rejectedCommandIds.add(command.commandId);
382 return Effect.fail(
383 new OrchestrationCommandInvariantError({
384 commandType: command.type,
385 detail: "Temporary history import failure.",
386 }),
387 );
388 }
389 historyImported = true;
390 }
391 return Effect.succeed({ sequence: 1 });
392 },
393 readEvents: () => Stream.empty,
394 readThreadEvents: () => Stream.empty,
395 getThreadReplayStats: () => Effect.die("unused"),
396 streamDomainEvents: Stream.empty,
397 subscribeDomainEvents: Effect.succeed(Stream.empty),
398 latestSequence: Effect.succeed(0),
399 });
400 const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
401 upsert: (binding) => {
402 bindingAttemptCount += 1;
403 if (bindingAttemptCount === 1) {
404 return Effect.fail(
405 new ProviderSessionDirectoryPersistenceError({
406 operation: "upsert",
407 detail: "Temporary session storage failure.",
408 }),
409 );
410 }
411 bindings.push(binding);
412 return Effect.void;
413 },
414 getProvider: () => Effect.die("unused"),
415 recordImportedTranscript: () => Effect.void,
416 getBinding: () =>
417 Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])),
418 listThreadIds: () => Effect.die("unused"),
419 listBindings: () => Effect.die("unused"),
420 });
421 const snapshots = makeSnapshotsLayer({
422 project: makeProject(),
423 getThread: () =>
424 threadCreated
425 ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported }))
426 : Option.none(),
427 });
428 const importOnce = () => runImport({ scanner, engine, directory, snapshots });
429
430 expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 });
431 expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 });
432 expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 });
433 const historyAttemptsAfterCompletion = historyAttemptCount;
434 expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 });
435 expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion);
436 expect(historyAttemptCount).toBe(2);
437 expect(bindings).toHaveLength(1);
438 }),
439 );
440
441 it.effect("does not replace completed history or an active binding on retry", () =>
442 Effect.gen(function* () {
443 const scanner = AgentSessionScanner.AgentSessionScanner.of({
444 scan: Effect.die("unused"),
445 recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]),
446 });
447 const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = {
448 threadId: ThreadId.make("import:codex:codex-session"),
449 provider: ProviderDriverKind.make("codex"),
450 providerInstanceId: ProviderInstanceId.make("codex"),
451 status: "running",
452 resumeCursor: { threadId: "newer-codex-session" },
453 };
454 const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
455 upsert: () => Effect.die("must not replace an active binding"),
456 getProvider: () => Effect.die("unused"),
457 recordImportedTranscript: () => Effect.void,
458 getBinding: () => Effect.succeed(Option.some(runningBinding)),
459 listThreadIds: () => Effect.die("unused"),
460 listBindings: () => Effect.die("unused"),
461 });
462 const engine = OrchestrationEngine.OrchestrationEngineService.of({
463 dispatch: () => Effect.die("must not replay history or settle active work"),
464 readEvents: () => Stream.empty,
465 readThreadEvents: () => Stream.empty,
466 getThreadReplayStats: () => Effect.die("unused"),
467 streamDomainEvents: Stream.empty,
468 subscribeDomainEvents: Effect.succeed(Stream.empty),
469 latestSequence: Effect.succeed(0),
470 });
471
472 const result = yield* runImport({
473 scanner,
474 engine,
475 directory,
476 snapshots: makeSnapshotsLayer({
477 project: makeProject(),
478 getThread: () =>
479 Option.some(
480 makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }),
481 ),
482 }),
483 });
484
485 expect(result).toEqual({ importedCount: 1, skippedCount: 0 });
486 }),
487 );
488
489 it.effect("skips malformed Claude ids and wrong-project thread collisions", () =>
490 Effect.gen(function* () {
491 const scanner = AgentSessionScanner.AgentSessionScanner.of({
492 scan: Effect.die("unused"),
493 recentThreads: () =>
494 Stream.fromIterable([
495 makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }),
496 makeThreadOutcome(makeThread("codex")),
497 ]),
498 });
499 const commands: Array<OrchestrationCommand> = [];
500 const engine = OrchestrationEngine.OrchestrationEngineService.of({
501 dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })),
502 readEvents: () => Stream.empty,
503 readThreadEvents: () => Stream.empty,
504 getThreadReplayStats: () => Effect.die("unused"),
505 streamDomainEvents: Stream.empty,
506 subscribeDomainEvents: Effect.succeed(Stream.empty),
507 latestSequence: Effect.succeed(0),
508 });
509 const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
510 upsert: () => Effect.die("must not bind malformed or wrong-project sessions"),
511 getProvider: () => Effect.die("unused"),
512 recordImportedTranscript: () => Effect.die("unused"),
513 getBinding: () => Effect.succeed(Option.none()),
514 listThreadIds: () => Effect.die("unused"),
515 listBindings: () => Effect.die("unused"),
516 });
517
518 const result = yield* runImport({
519 scanner,
520 engine,
521 directory,
522 snapshots: makeSnapshotsLayer({
523 project: makeProject(),
524 getThread: (threadId) =>
525 threadId === "import:codex:codex-session"
526 ? Option.some(
527 makeProjectedThread({
528 source: "codex",
529 projectId: ProjectId.make("project-other"),
530 }),
531 )
532 : Option.none(),
533 }),
534 });
535
536 expect(result).toEqual({ importedCount: 0, skippedCount: 2 });
537 expect(commands).toHaveLength(0);
538 }),
539 );
540 });
541});
542
543const integrationThread = {
544 ...makeThread("codex"),
545 updatedAt: "2026-08-24T10:00:00.000Z",
546 messages: Array.from({ length: 12 }, (_, index) => ({
547 role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
548 text: `Message ${index}`,
549 createdAt: "2026-08-24T10:00:00.000Z",
550 })),
551};
552const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({
553 scan: Effect.die("unused"),
554 recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]),
555});
556const integrationServerConfig = ServerConfig.layerTest(process.cwd(), {
557 prefix: "t3-agent-session-importer-test-",
558});
559const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe(
560 Layer.provide(SqlitePersistenceMemory),
561);
562const integrationLayer = Layer.mergeAll(
563 OrchestrationEngineLive.pipe(
564 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
565 Layer.provide(OrchestrationProjectionPipelineLive),
566 ),
567 OrchestrationProjectionSnapshotQueryLive,
568 integrationRuntimeRepository,
569 ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)),
570 Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner),
571).pipe(
572 Layer.provide(ThreadBackgroundLiveness.layer),
573 Layer.provide(ThreadPlanProgress.layer),
574 Layer.provide(OrchestrationEventStoreLive),
575 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
576 Layer.provide(RepositoryIdentityResolver.layer),
577 Layer.provide(SqlitePersistenceMemory),
578 Layer.provideMerge(integrationServerConfig),
579 Layer.provideMerge(NodeServices.layer),
580);
581
582it.layer(integrationLayer)("AgentSessionImporter integration", (it) => {
583 it.effect("imports once after the real engine persists an old rejected receipt", () =>
584 Effect.gen(function* () {
585 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
586 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
587 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
588 const threadId = ThreadId.make("import:codex:codex-session");
589
590 yield* engine.dispatch({
591 type: "project.create",
592 commandId: CommandId.make("create-import-integration-project"),
593 projectId: PROJECT_ID,
594 title: "Project",
595 workspaceRoot: WORKSPACE_ROOT,
596 defaultModelSelection: null,
597 createdAt: "2026-08-24T09:00:00.000Z",
598 });
599 const rejected = yield* Effect.result(
600 engine.dispatch({
601 type: "thread.history.import",
602 commandId: CommandId.make(`agent-session:history:${threadId}`),
603 threadId,
604 messages: [
605 {
606 messageId: MessageId.make(`${threadId}:000000`),
607 role: "user",
608 text: "Fix the bug",
609 createdAt: "2026-08-24T10:00:00.000Z",
610 },
611 ],
612 }),
613 );
614 expect(rejected._tag).toBe("Failure");
615
616 const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID });
617 const importedThread = yield* snapshots.getThreadDetailById(threadId);
618 const binding = yield* directory.getBinding(threadId);
619
620 expect(result).toEqual({ importedCount: 1, skippedCount: 0 });
621 expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual(
622 integrationThread.messages.map((message) => message.text),
623 );
624 expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled");
625 expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z");
626 expect(Option.getOrThrow(binding)).toMatchObject({
627 provider: "codex",
628 providerInstanceId: "codex",
629 resumeCursor: { threadId: "codex-session" },
630 runtimePayload: { cwd: WORKSPACE_ROOT },
631 });
632
633 yield* engine.dispatch({
634 type: "thread.revert.complete",
635 commandId: CommandId.make("revert-imported-thread-to-baseline"),
636 threadId,
637 turnCount: 0,
638 createdAt: "2026-08-24T10:05:00.000Z",
639 });
640 const afterRevert = yield* snapshots.getThreadDetailById(threadId);
641 expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual(
642 integrationThread.messages.map((message) => message.text),
643 );
644 }),
645 );
646
647 it.effect(
648 "retries a bounded import after scanner restart without rereading completed transcripts",
649 () =>
650 Effect.gen(function* () {
651 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
652 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
653 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
654 const fileSystem = yield* FileSystem.FileSystem;
655 const path = yield* Path.Path;
656 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
657 yield* TestClock.setTime(nowMs);
658 const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({
659 prefix: "t3-import-retry-",
660 });
661 const workspaceRoot = path.join(fixtureDir, "workspace");
662 const claudeHomePath = path.join(fixtureDir, "claude");
663 const codexHomePath = path.join(fixtureDir, "codex");
664 const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24");
665 yield* fileSystem.makeDirectory(workspaceRoot);
666 yield* fileSystem.makeDirectory(claudeHomePath);
667 yield* fileSystem.makeDirectory(sessionsDir, { recursive: true });
668
669 const projectId = ProjectId.make("project-bounded-import-retry");
670 const transcripts = Array.from({ length: 101 }, (_, index) => {
671 const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`;
672 return {
673 providerSessionId,
674 threadId: ThreadId.make(`import:codex:${providerSessionId}`),
675 filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`),
676 };
677 });
678 for (const [index, transcript] of transcripts.entries()) {
679 yield* fileSystem.writeFileString(
680 transcript.filePath,
681 [
682 encodeTranscriptRecord({
683 type: "session_meta",
684 payload: { id: transcript.providerSessionId, cwd: workspaceRoot },
685 }),
686 encodeTranscriptRecord({
687 type: "event_msg",
688 payload: {
689 type: "user_message",
690 message: `Prompt ${transcript.providerSessionId}`,
691 },
692 }),
693 ].join("\n"),
694 );
695 const seconds = nowMs / 1_000 - index;
696 yield* fileSystem.utimes(transcript.filePath, seconds, seconds);
697 }
698 const legacy = transcripts[0]!;
699 const failed = transcripts[1]!;
700 const remaining = transcripts[100]!;
701 yield* engine.dispatch({
702 type: "project.create",
703 commandId: CommandId.make("create-bounded-import-project"),
704 projectId,
705 title: "Bounded import",
706 workspaceRoot,
707 defaultModelSelection: null,
708 createdAt: "2026-08-24T09:00:00.000Z",
709 });
710
711 // This completed import predates persisted transcript source metadata.
712 yield* directory.upsert({
713 threadId: legacy.threadId,
714 provider: ProviderDriverKind.make("codex"),
715 providerInstanceId: ProviderInstanceId.make("codex"),
716 status: "stopped",
717 resumeCursor: { threadId: "legacy-current-session" },
718 runtimePayload: { cwd: workspaceRoot },
719 });
720 yield* engine.dispatch({
721 type: "thread.create",
722 commandId: CommandId.make("create-legacy-bounded-import"),
723 threadId: legacy.threadId,
724 projectId,
725 title: "Legacy import",
726 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" },
727 runtimeMode: "full-access",
728 interactionMode: "default",
729 branch: null,
730 worktreePath: null,
731 createdAt: "2026-08-24T10:00:00.000Z",
732 historyImport: true,
733 });
734 yield* engine.dispatch({
735 type: "thread.history.import",
736 commandId: CommandId.make("import-legacy-bounded-history"),
737 threadId: legacy.threadId,
738 messages: [
739 {
740 messageId: MessageId.make(`${legacy.threadId}:000000`),
741 role: "user",
742 text: "Legacy imported history",
743 createdAt: "2026-08-24T10:00:00.000Z",
744 },
745 ],
746 });
747 expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]);
748
749 let failHistory = true;
750 const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({
751 ...engine,
752 dispatch: (command) => {
753 if (
754 failHistory &&
755 command.type === "thread.history.import" &&
756 command.threadId === failed.threadId
757 ) {
758 failHistory = false;
759 return Effect.fail(
760 new OrchestrationCommandInvariantError({
761 commandType: command.type,
762 detail: "Injected history import failure.",
763 }),
764 );
765 }
766 return engine.dispatch(command);
767 },
768 });
769 const settingsLayer = ServerSettingsService.layerTest({
770 providers: {
771 claudeAgent: { homePath: claudeHomePath },
772 codex: { homePath: codexHomePath },
773 },
774 });
775 const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath));
776 const runAttempt = Effect.fn("runBoundedImportAttempt")(function* (
777 completedPaths: ReadonlySet<string>,
778 ) {
779 const openCounts = new Map<string, number>();
780 const fullReads: string[] = [];
781 const observedFileSystem = FileSystem.FileSystem.of({
782 ...fileSystem,
783 open: (filePath, options) =>
784 Effect.suspend(() => {
785 if (transcriptPaths.has(filePath)) {
786 const count = (openCounts.get(filePath) ?? 0) + 1;
787 openCounts.set(filePath, count);
788 // A fresh scanner first opens each file for project discovery.
789 if (count > 1) {
790 fullReads.push(filePath);
791 if (completedPaths.has(filePath)) {
792 return Effect.die(new Error(`Completed transcript reopened: ${filePath}`));
793 }
794 }
795 }
796 return fileSystem.open(filePath, options);
797 }),
798 });
799 const result = yield* importRecentAgentThreads({ projectId }).pipe(
800 Effect.provide(
801 Layer.fresh(AgentSessionScanner.layer).pipe(
802 Layer.provide(settingsLayer),
803 Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)),
804 ),
805 ),
806 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine),
807 );
808 return { result, fullReads, openCounts };
809 });
810
811 const first = yield* runAttempt(new Set());
812 expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 });
813 expect(failHistory).toBe(false);
814 expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath));
815 expect(first.openCounts.get(remaining.filePath)).toBe(1);
816 const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId);
817 expect(completedSources).toHaveLength(99);
818 expect(completedSources).toContainEqual({
819 threadId: legacy.threadId,
820 source: expect.objectContaining({ filePath: legacy.filePath }),
821 });
822 expect(
823 Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages,
824 ).toEqual([]);
825 expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({
826 status: "stopped",
827 resumeCursor: { threadId: failed.providerSessionId },
828 });
829 expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true);
830
831 const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath));
832 const second = yield* runAttempt(completedPaths);
833 expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 });
834 expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]);
835 for (const transcript of transcripts) {
836 expect(second.openCounts.get(transcript.filePath)).toBe(
837 completedPaths.has(transcript.filePath) ? 1 : 2,
838 );
839 }
840 expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101);
841 expect(
842 Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map(
843 (message) => message.text,
844 ),
845 ).toEqual(["Legacy imported history"]);
846 expect(
847 Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor,
848 ).toEqual({
849 threadId: "legacy-current-session",
850 });
851 for (const transcript of [failed, remaining]) {
852 expect(
853 Option.getOrThrow(
854 yield* snapshots.getThreadDetailById(transcript.threadId),
855 ).messages.map((message) => message.text),
856 ).toEqual([`Prompt ${transcript.providerSessionId}`]);
857 }
858 }),
859 );
860
861 for (const source of ["codex", "claudeAgent"] as const) {
862 it.effect(`resumes imported ${source} history only after the first prompt`, () =>
863 Effect.gen(function* () {
864 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
865 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
866 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
867 const fileSystem = yield* FileSystem.FileSystem;
868 const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped();
869 const projectId = ProjectId.make(`project-import-resume-${source}`);
870 const sourceThread = {
871 ...makeThread(source),
872 providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID,
873 };
874 const threadId = ThreadId.make(
875 `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`,
876 );
877 const resumeCursor =
878 source === "codex"
879 ? { threadId: sourceThread.providerSessionId }
880 : { threadId, resume: sourceThread.providerSessionId };
881 const provider = ProviderDriverKind.make(source);
882 const harness = yield* makeTestProviderAdapterHarness({ provider });
883 const importSettled = yield* Deferred.make<void>();
884 const turnSent = yield* Deferred.make<void>();
885 const startSession = vi.fn(harness.adapter.startSession);
886 const sendTurn = vi.fn((input: ProviderSendTurnInput) =>
887 harness.adapter
888 .sendTurn(input)
889 .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))),
890 );
891 const providerLayer = makeProviderServiceLive().pipe(
892 Layer.provide(
893 Layer.succeed(
894 ProviderAdapterRegistry,
895 makeAdapterRegistryMock({
896 [provider]: { ...harness.adapter, startSession, sendTurn },
897 }),
898 ),
899 ),
900 Layer.provide(
901 Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),
902 ),
903 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
904 Layer.provide(AnalyticsService.layerTest),
905 );
906 const reactorLayer = ProviderCommandReactorLive.pipe(
907 Layer.provideMerge(providerLayer),
908 Layer.provide(
909 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
910 ...snapshots,
911 // Acknowledge the imported settlement before draining the reactor.
912 getThreadShellById: (requestedThreadId) =>
913 snapshots
914 .getThreadShellById(requestedThreadId)
915 .pipe(
916 Effect.tap(() =>
917 requestedThreadId === threadId
918 ? Deferred.succeed(importSettled, undefined)
919 : Effect.void,
920 ),
921 ),
922 }),
923 ),
924 Layer.provide(
925 Layer.mock(ProviderAuthService)({
926 tryHandlePromptCommand: () => Effect.succeed(false),
927 }),
928 ),
929 Layer.provide(makeProviderRegistryLayer()),
930 Layer.provide(Layer.mock(GitWorkflowService)({})),
931 Layer.provide(Layer.mock(VcsStatusBroadcaster)({})),
932 Layer.provide(Layer.mock(TextGeneration)({})),
933 Layer.provide(ServerSettingsService.layerTest()),
934 );
935
936 yield* engine.dispatch({
937 type: "project.create",
938 commandId: CommandId.make(`create-import-resume-project-${source}`),
939 projectId,
940 title: "Import resume",
941 workspaceRoot,
942 defaultModelSelection: null,
943 createdAt: "2026-08-24T09:00:00.000Z",
944 });
945 yield* harness.queueTurnResponseForNextSession({ events: [] });
946
947 yield* Effect.gen(function* () {
948 const reactor = yield* ProviderCommandReactor;
949 yield* reactor.start();
950 expect(yield* importRecentAgentThreads({ projectId })).toEqual({
951 importedCount: 1,
952 skippedCount: 0,
953 });
954 yield* Deferred.await(importSettled);
955 yield* reactor.drain;
956 expect(startSession).not.toHaveBeenCalled();
957 expect(sendTurn).not.toHaveBeenCalled();
958 const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId));
959 expect(importedThread.session).toBeNull();
960 expect(importedThread.latestTurn).toBeNull();
961
962 yield* engine.dispatch({
963 type: "thread.turn.start",
964 commandId: CommandId.make(`resume-imported-${source}`),
965 threadId,
966 message: {
967 messageId: MessageId.make(`resume-imported-message-${source}`),
968 role: "user",
969 text: "Continue this session",
970 attachments: [],
971 },
972 modelSelection: importedThread.modelSelection,
973 runtimeMode: importedThread.runtimeMode,
974 interactionMode: importedThread.interactionMode,
975 createdAt: "2026-08-24T10:02:00.000Z",
976 });
977 yield* Deferred.await(turnSent);
978 yield* reactor.drain;
979 expect(startSession).toHaveBeenCalledExactlyOnceWith(
980 expect.objectContaining({
981 threadId,
982 provider,
983 providerInstanceId: sourceThread.providerInstanceId,
984 resumeCursor,
985 cwd: workspaceRoot,
986 }),
987 );
988 expect(sendTurn).toHaveBeenCalledExactlyOnceWith(
989 expect.objectContaining({ threadId, input: "Continue this session" }),
990 );
991 expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
992 provider,
993 providerInstanceId: sourceThread.providerInstanceId,
994 resumeCursor,
995 });
996 }).pipe(
997 Effect.provide(reactorLayer),
998 Effect.provideService(
999 AgentSessionScanner.AgentSessionScanner,
1000 AgentSessionScanner.AgentSessionScanner.of({
1001 scan: Effect.die("unused"),
1002 recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)),
1003 }),
1004 ),
1005 );
1006 }),
1007 );
1008 }
1009
1010 it.effect("persists the resume cursor before publishing a new imported thread", () =>
1011 Effect.gen(function* () {
1012 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
1013 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
1014 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
1015 const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
1016 const projectId = ProjectId.make("project-import-binding-race");
1017 const workspaceRoot = "/tmp/project-import-binding-race";
1018 const providerSessionId = "codex-binding-race";
1019 const threadId = ThreadId.make(`import:codex:${providerSessionId}`);
1020 const scanner = AgentSessionScanner.AgentSessionScanner.of({
1021 scan: Effect.die("unused"),
1022 recentThreads: () =>
1023 Stream.succeed(
1024 makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }),
1025 ),
1026 });
1027 const importerAtBindingWrite = yield* Deferred.make<void>();
1028 const releaseImporter = yield* Deferred.make<void>();
1029 const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({
1030 ...repository,
1031 upsert: (runtime, options) =>
1032 options?.onConflict === "ignore"
1033 ? Deferred.succeed(importerAtBindingWrite, undefined).pipe(
1034 Effect.andThen(Deferred.await(releaseImporter)),
1035 Effect.andThen(repository.upsert(runtime, options)),
1036 )
1037 : repository.upsert(runtime, options),
1038 });
1039 const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe(
1040 Effect.provide(
1041 Layer.fresh(ProviderSessionDirectoryLive).pipe(
1042 Layer.provide(
1043 Layer.succeed(
1044 ProviderSessionRuntime.ProviderSessionRuntimeRepository,
1045 importerRepository,
1046 ),
1047 ),
1048 ),
1049 ),
1050 );
1051
1052 yield* engine.dispatch({
1053 type: "project.create",
1054 commandId: CommandId.make("create-import-binding-race-project"),
1055 projectId,
1056 title: "Binding race",
1057 workspaceRoot,
1058 defaultModelSelection: null,
1059 createdAt: "2026-08-24T09:00:00.000Z",
1060 });
1061
1062 const importFiber = yield* importRecentAgentThreads({ projectId }).pipe(
1063 Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner),
1064 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory),
1065 Effect.forkChild,
1066 );
1067
1068 yield* Effect.raceFirst(
1069 Deferred.await(importerAtBindingWrite),
1070 Fiber.join(importFiber).pipe(
1071 Effect.flatMap((result) =>
1072 Effect.die(
1073 new Error(`Import completed before the binding write: ${JSON.stringify(result)}`),
1074 ),
1075 ),
1076 ),
1077 );
1078 expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true);
1079
1080 yield* directory.upsert({
1081 threadId,
1082 provider: ProviderDriverKind.make("codex"),
1083 providerInstanceId: ProviderInstanceId.make("codex"),
1084 status: "running",
1085 resumeCursor: { threadId: "active-client-session" },
1086 runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
1087 });
1088 yield* Deferred.succeed(releaseImporter, undefined);
1089
1090 expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 });
1091 expect(
1092 Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map(
1093 (message) => message.text,
1094 ),
1095 ).toEqual(integrationThread.messages.map((message) => message.text));
1096 expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
1097 status: "running",
1098 resumeCursor: { threadId: "active-client-session" },
1099 runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
1100 });
1101 }),
1102 );
1103
1104 it.effect("does not import history over a turn started on a partial thread", () =>
1105 Effect.gen(function* () {
1106 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
1107 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
1108 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
1109 const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
1110 const projectId = ProjectId.make("project-import-turn-race");
1111 const workspaceRoot = "/tmp/project-import-turn-race";
1112 const providerSessionId = "codex-turn-race";
1113 const threadId = ThreadId.make(`import:codex:${providerSessionId}`);
1114 const scanner = AgentSessionScanner.AgentSessionScanner.of({
1115 scan: Effect.die("unused"),
1116 recentThreads: () =>
1117 Stream.succeed(
1118 makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }),
1119 ),
1120 });
1121 const importerAtBindingWrite = yield* Deferred.make<void>();
1122 const releaseImporter = yield* Deferred.make<void>();
1123 const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({
1124 ...repository,
1125 upsert: (runtime, options) =>
1126 options?.onConflict === "ignore"
1127 ? Deferred.succeed(importerAtBindingWrite, undefined).pipe(
1128 Effect.andThen(Deferred.await(releaseImporter)),
1129 Effect.andThen(repository.upsert(runtime, options)),
1130 )
1131 : repository.upsert(runtime, options),
1132 });
1133 const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe(
1134 Effect.provide(
1135 Layer.fresh(ProviderSessionDirectoryLive).pipe(
1136 Layer.provide(
1137 Layer.succeed(
1138 ProviderSessionRuntime.ProviderSessionRuntimeRepository,
1139 importerRepository,
1140 ),
1141 ),
1142 ),
1143 ),
1144 );
1145
1146 yield* engine.dispatch({
1147 type: "project.create",
1148 commandId: CommandId.make("create-import-turn-race-project"),
1149 projectId,
1150 title: "Turn race",
1151 workspaceRoot,
1152 defaultModelSelection: null,
1153 createdAt: "2026-08-24T09:00:00.000Z",
1154 });
1155 yield* engine.dispatch({
1156 type: "thread.create",
1157 commandId: CommandId.make("create-import-turn-race-thread"),
1158 threadId,
1159 projectId,
1160 title: "Turn race",
1161 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" },
1162 runtimeMode: "full-access",
1163 interactionMode: "default",
1164 branch: null,
1165 worktreePath: null,
1166 createdAt: "2026-08-24T10:00:00.000Z",
1167 });
1168
1169 const importFiber = yield* importRecentAgentThreads({ projectId }).pipe(
1170 Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner),
1171 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory),
1172 Effect.forkChild,
1173 );
1174 yield* Deferred.await(importerAtBindingWrite);
1175
1176 yield* directory.upsert({
1177 threadId,
1178 provider: ProviderDriverKind.make("codex"),
1179 providerInstanceId: ProviderInstanceId.make("codex"),
1180 status: "running",
1181 resumeCursor: { threadId: "active-client-session" },
1182 runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
1183 });
1184 yield* engine.dispatch({
1185 type: "thread.turn.start",
1186 commandId: CommandId.make("start-turn-during-import"),
1187 threadId,
1188 message: {
1189 messageId: MessageId.make("message-during-import"),
1190 role: "user",
1191 text: "Continue while import waits",
1192 attachments: [],
1193 },
1194 runtimeMode: "full-access",
1195 interactionMode: "default",
1196 createdAt: "2026-08-24T10:02:00.000Z",
1197 });
1198 yield* Deferred.succeed(releaseImporter, undefined);
1199
1200 expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 });
1201 expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
1202 status: "running",
1203 resumeCursor: { threadId: "active-client-session" },
1204 runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
1205 });
1206 expect(
1207 Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map(
1208 (message) => message.text,
1209 ),
1210 ).toEqual(["Continue while import waits"]);
1211 }),
1212 );
1213});
apps/server/src/project/AgentSessionImporter.ts created+297
...@@ -0,0 +1,297 @@
1import {
2 CommandId,
3 DEFAULT_MODEL,
4 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_PROVIDER_INTERACTION_MODE,
6 DEFAULT_RUNTIME_MODE,
7 AgentSessionImportProjectChangedError,
8 AgentSessionImportProjectNotFoundError,
9 AgentSessionSource,
10 AgentSessionScanError,
11 isImportedAgentSessionMessageId,
12 MessageId,
13 ProjectId,
14 ProviderDriverKind,
15 ThreadId,
16 type AgentSessionImportInput,
17 type AgentSessionImportResult,
18 type OrchestrationThread,
19} from "@t3tools/contracts";
20import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
21import * as Crypto from "effect/Crypto";
22import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";
26
27import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
28import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
29import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts";
30import * as AgentSessionScanner from "./AgentSessionScanner.ts";
31
32const CLAUDE_SESSION_ID_PATTERN =
33 /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
34
35class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass<AgentSessionUnresumableSessionError>()(
36 "AgentSessionUnresumableSessionError",
37 {
38 source: AgentSessionSource,
39 providerSessionId: Schema.String,
40 },
41) {
42 override get message(): string {
43 return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`;
44 }
45}
46
47class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass<AgentSessionThreadProjectConflictError>()(
48 "AgentSessionThreadProjectConflictError",
49 {
50 threadId: ThreadId,
51 expectedProjectId: ProjectId,
52 actualProjectId: ProjectId,
53 },
54) {
55 override get message(): string {
56 return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`;
57 }
58}
59
60class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass<AgentSessionThreadModifiedError>()(
61 "AgentSessionThreadModifiedError",
62 { threadId: ThreadId },
63) {
64 override get message(): string {
65 return `Imported thread '${this.threadId}' changed before its history import completed.`;
66 }
67}
68
69function hasImportedHistory(thread: OrchestrationThread): boolean {
70 return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id));
71}
72
73function hasImportBlockingActivity(
74 thread: OrchestrationThread,
75 importedHistoryPresent: boolean,
76): boolean {
77 return (
78 thread.archivedAt !== null ||
79 thread.deletedAt !== null ||
80 thread.latestTurn !== null ||
81 thread.session !== null ||
82 thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) ||
83 thread.proposedPlans.length > 0 ||
84 thread.activities.length > 0 ||
85 thread.checkpoints.length > 0 ||
86 thread.snoozedUntil != null ||
87 thread.snoozedAt != null ||
88 thread.pinnedAt != null ||
89 thread.pinOrderKey != null ||
90 thread.titleRegeneration != null ||
91 thread.linkedPullRequest != null ||
92 thread.unsettledAt != null ||
93 (importedHistoryPresent
94 ? thread.settledOverride !== "settled"
95 : thread.settledOverride !== null || thread.settledAt !== null)
96 );
97}
98
99/** Import recent transcript text and persist the cursor needed to resume its provider session. */
100export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* (
101 input: AgentSessionImportInput,
102) {
103 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
104 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
105 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
106 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
107 const crypto = yield* Crypto.Crypto;
108 const project = yield* snapshots.getProjectShellById(input.projectId).pipe(
109 Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })),
110 Effect.flatMap(
111 Option.match({
112 onNone: () =>
113 Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })),
114 onSome: Effect.succeed,
115 }),
116 ),
117 );
118 const workspaceRoot = project.workspaceRoot;
119 if (
120 input.expectedWorkspaceRoot !== undefined &&
121 normalizeProjectPathForComparison(workspaceRoot) !==
122 normalizeProjectPathForComparison(input.expectedWorkspaceRoot)
123 ) {
124 return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId });
125 }
126 const completedSources = yield* snapshots
127 .getImportedAgentSessionSources(input.projectId)
128 .pipe(
129 Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })),
130 );
131 const threads = scanner.recentThreads(
132 workspaceRoot,
133 completedSources.map((entry) => entry.source),
134 );
135 const importedThreadIds = new Set<ThreadId>();
136 let importedCount = 0;
137 let skippedCount = 0;
138
139 yield* Stream.runForEach(threads, (outcome) =>
140 Effect.gen(function* () {
141 if (outcome._tag === "Skipped") {
142 skippedCount += 1;
143 return;
144 }
145 if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") {
146 const threadId = ThreadId.make(
147 `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`,
148 );
149 if (outcome._tag === "AlreadyImported") {
150 importedThreadIds.add(threadId);
151 importedCount += 1;
152 } else if (importedThreadIds.has(threadId)) {
153 const recorded = yield* directory
154 .recordImportedTranscript({ threadId, source: outcome.source })
155 .pipe(Effect.result);
156 if (recorded._tag === "Failure") {
157 skippedCount += 1;
158 yield* Effect.logWarning("Could not record an imported transcript copy", {
159 threadId,
160 cause: recorded.failure,
161 });
162 }
163 }
164 return;
165 }
166 const thread = outcome.thread;
167 const threadId = ThreadId.make(
168 `import:${thread.providerInstanceId}:${thread.providerSessionId}`,
169 );
170 const imported = yield* Effect.gen(function* () {
171 const provider = ProviderDriverKind.make(thread.source);
172 const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
173 const existingThread = yield* snapshots.getThreadDetailById(threadId);
174 const existingBinding = yield* directory.getBinding(threadId);
175
176 if (
177 thread.source === "claudeAgent" &&
178 !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId)
179 ) {
180 return yield* new AgentSessionUnresumableSessionError({
181 source: thread.source,
182 providerSessionId: thread.providerSessionId,
183 });
184 }
185
186 if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) {
187 return yield* new AgentSessionThreadProjectConflictError({
188 threadId,
189 expectedProjectId: input.projectId,
190 actualProjectId: existingThread.value.projectId,
191 });
192 }
193
194 const importedHistoryPresent = Option.isSome(existingThread)
195 ? hasImportedHistory(existingThread.value)
196 : false;
197 if (
198 Option.isSome(existingThread) &&
199 importedHistoryPresent &&
200 Option.isSome(existingBinding)
201 ) {
202 yield* directory.recordImportedTranscript({ threadId, source: outcome.source });
203 return true;
204 }
205
206 if (
207 Option.isSome(existingThread) &&
208 hasImportBlockingActivity(existingThread.value, importedHistoryPresent)
209 ) {
210 return yield* new AgentSessionThreadModifiedError({ threadId });
211 }
212
213 if (
214 Option.isSome(existingBinding) &&
215 (existingBinding.value.provider !== provider ||
216 existingBinding.value.providerInstanceId !== thread.providerInstanceId ||
217 existingBinding.value.status !== "stopped")
218 ) {
219 return yield* new AgentSessionThreadModifiedError({ threadId });
220 }
221
222 // Install the cursor before the thread becomes visible. A concurrent
223 // real session can replace it, while insert-ignore keeps this import
224 // from replacing that newer binding.
225 if (Option.isNone(existingBinding)) {
226 yield* directory.upsert(
227 {
228 threadId,
229 provider,
230 providerInstanceId: thread.providerInstanceId,
231 status: "stopped",
232 runtimeMode: DEFAULT_RUNTIME_MODE,
233 resumeCursor:
234 thread.source === "codex"
235 ? { threadId: thread.providerSessionId }
236 : { threadId, resume: thread.providerSessionId },
237 runtimePayload: { cwd: workspaceRoot },
238 },
239 { onConflict: "ignore" },
240 );
241 }
242
243 if (Option.isNone(existingThread)) {
244 yield* engine.dispatch({
245 type: "thread.create",
246 commandId: CommandId.make(yield* crypto.randomUUIDv4),
247 threadId,
248 projectId: input.projectId,
249 title: thread.title,
250 modelSelection: { instanceId: thread.providerInstanceId, model },
251 runtimeMode: DEFAULT_RUNTIME_MODE,
252 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
253 branch: null,
254 worktreePath: null,
255 createdAt: thread.createdAt,
256 historyImport: true,
257 });
258 }
259
260 if (!importedHistoryPresent) {
261 yield* engine.dispatch({
262 type: "thread.history.import",
263 commandId: CommandId.make(yield* crypto.randomUUIDv4),
264 threadId,
265 messages: thread.messages.map((message, index) => ({
266 messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`),
267 role: message.role,
268 text: message.text,
269 createdAt: message.createdAt,
270 })),
271 });
272 }
273
274 yield* directory.recordImportedTranscript({ threadId, source: outcome.source });
275
276 return true;
277 }).pipe(
278 Effect.catch((cause) =>
279 Effect.logWarning("Could not import an agent session", {
280 provider: thread.source,
281 sessionId: thread.providerSessionId,
282 cause,
283 }).pipe(Effect.as(false)),
284 ),
285 );
286
287 if (imported) {
288 importedThreadIds.add(threadId);
289 importedCount += 1;
290 } else {
291 skippedCount += 1;
292 }
293 }),
294 );
295
296 return { importedCount, skippedCount } satisfies AgentSessionImportResult;
297});
apps/server/src/project/AgentSessionScanner.test.ts created+3088
...@@ -0,0 +1,3088 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeOS from "node:os";
3import { describe, expect, it } from "@effect/vitest";
4import {
5 type OrchestrationProjectShell,
6 ProjectId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 type ServerSettings as ContractServerSettings,
10} from "@t3tools/contracts";
11import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import * as TestClock from "effect/testing/TestClock";
20
21import * as ServerConfig from "../config.ts";
22import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
23import * as ServerSettings from "../serverSettings.ts";
24import * as AgentSessionScanner from "./AgentSessionScanner.ts";
25
26const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({
27 id: ProjectId.make("project-1"),
28 title: "Imported",
29 workspaceRoot,
30 defaultModelSelection: null,
31 scripts: [],
32 createdAt: "2026-01-01T00:00:00.000Z",
33 updatedAt: "2026-01-01T00:00:00.000Z",
34});
35
36/** Only `getShellSnapshot` is exercised; the rest must not be called. */
37const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray<string>) =>
38 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
39 getCommandReadModel: () => Effect.die("unused"),
40 getUserInputActivity: () => Effect.die("unused"),
41 getSnapshot: () => Effect.die("unused"),
42 getShellSnapshot: () =>
43 Effect.succeed({
44 snapshotSequence: 0,
45 projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)),
46 threads: [],
47 updatedAt: "2026-01-01T00:00:00.000Z",
48 }),
49 getArchivedShellSnapshot: () => Effect.die("unused"),
50 getSnapshotSequence: () => Effect.die("unused"),
51 getCounts: () => Effect.die("unused"),
52 getEventReplayStats: () => Effect.die("unused"),
53 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
54 getProjectShellById: () => Effect.die("unused"),
55 getImportedAgentSessionSources: () => Effect.succeed([]),
56 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
57 getThreadCheckpointContext: () => Effect.die("unused"),
58 getFullThreadDiffContext: () => Effect.die("unused"),
59 getThreadShellById: () => Effect.die("unused"),
60 getThreadRuntimeContext: () => Effect.die("unused"),
61 getThreadDetailById: () => Effect.die("unused"),
62 getThreadDetailSnapshot: () => Effect.die("unused"),
63 searchThreads: () => Effect.die("unused"),
64 });
65
66/**
67 * Run a scan against the given homes. Homes are temp dirs created inside the
68 * test, so the layer is built per run rather than shared.
69 */
70interface ScannerTestInput {
71 readonly claudeHomePath: string;
72 readonly codexHomePath: string;
73 readonly importedWorkspaceRoots?: ReadonlyArray<string>;
74 /** Base dir for the test ServerConfig; worktreesDir derives from it. */
75 readonly configBaseDir?: string;
76 readonly providerInstances?: ContractServerSettings["providerInstances"];
77}
78
79const makeScannerTestLayer = (input: ScannerTestInput) =>
80 AgentSessionScanner.layer.pipe(
81 Layer.provide(
82 Layer.mergeAll(
83 ServerSettings.layerTest({
84 providers: {
85 claudeAgent: { homePath: input.claudeHomePath },
86 codex: { homePath: input.codexHomePath },
87 },
88 ...(input.providerInstances === undefined
89 ? {}
90 : { providerInstances: input.providerInstances }),
91 }),
92 ServerConfig.layerTest(
93 input.claudeHomePath,
94 input.configBaseDir ?? { prefix: "t3code-scanner-config-" },
95 ),
96 makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []),
97 ),
98 ),
99 );
100
101const runScan = (input: ScannerTestInput) =>
102 Effect.gen(function* () {
103 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
104 return yield* scanner.scan;
105 }).pipe(Effect.provide(makeScannerTestLayer(input)));
106
107const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) =>
108 Effect.gen(function* () {
109 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
110 return yield* scanner.recentThreads(input.workspaceRoot).pipe(
111 Stream.runCollect,
112 Effect.map((outcomes) => Array.from(outcomes)),
113 );
114 }).pipe(Effect.provide(makeScannerTestLayer(input)));
115
116const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) =>
117 runRecentThreadOutcomes(input).pipe(
118 Effect.map((outcomes) =>
119 outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])),
120 ),
121 );
122
123const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) {
124 const fileSystem = yield* FileSystem.FileSystem;
125 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
126});
127
128const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: {
129 readonly filePath: string;
130 readonly contents: string;
131 /** Epoch millis, so ordering assertions never depend on write timing. */
132 readonly mtimeMs: number;
133}) {
134 const fileSystem = yield* FileSystem.FileSystem;
135 const path = yield* Path.Path;
136 yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true });
137 yield* fileSystem.writeFileString(input.filePath, input.contents);
138 // Numeric utimes arguments are seconds, not milliseconds.
139 const seconds = input.mtimeMs / 1000;
140 yield* fileSystem.utimes(input.filePath, seconds, seconds);
141});
142
143/** Claude session line: the first record carries the real `cwd`. */
144const claudeSessionLine = (cwd: string) =>
145 `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`;
146
147/** Codex rollout line: session metadata is nested under `payload`. */
148const codexRolloutLine = (cwd: string) =>
149 `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`;
150
151const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
152
153function makeRecordLimitTranscript(cwd: string, overflow: boolean): string {
154 const records =
155 [
156 encodeTranscriptRecord({
157 type: "session_meta",
158 payload: { id: "record-limit-session", cwd },
159 }),
160 encodeTranscriptRecord({
161 type: "event_msg",
162 payload: { type: "user_message", message: "First prompt" },
163 }),
164 ].join("\n") +
165 "\n" +
166 "{}\n".repeat(99_998);
167 return overflow
168 ? records +
169 "\n" +
170 encodeTranscriptRecord({
171 type: "event_msg",
172 payload: { type: "user_message", message: "Overflow prompt" },
173 }) +
174 "\n"
175 : records;
176}
177
178it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
179 describe("scan", () => {
180 it.effect("reads Claude project cwds from transcripts, newest first", () =>
181 Effect.gen(function* () {
182 const path = yield* Path.Path;
183 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
184 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
185 const olderWorkspace = yield* makeTempDir("t3code-workspace-older-");
186 const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-");
187
188 // Slugs are intentionally lossy; the scanner must not decode them.
189 yield* writeTranscript({
190 filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"),
191 contents: claudeSessionLine(olderWorkspace),
192 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
193 });
194 yield* writeTranscript({
195 filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"),
196 contents: claudeSessionLine(olderWorkspace),
197 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
198 });
199 yield* writeTranscript({
200 filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"),
201 contents: claudeSessionLine(newerWorkspace),
202 mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"),
203 });
204
205 const result = yield* runScan({ claudeHomePath, codexHomePath });
206
207 expect(result.candidates).toEqual([
208 {
209 path: newerWorkspace,
210 title: path.basename(newerWorkspace),
211 sources: ["claudeAgent"],
212 threadCount: 1,
213 lastActiveAt: "2026-03-01T00:00:00.000Z",
214 alreadyImported: false,
215 },
216 {
217 path: olderWorkspace,
218 title: path.basename(olderWorkspace),
219 sources: ["claudeAgent"],
220 threadCount: 2,
221 lastActiveAt: "2026-01-02T00:00:00.000Z",
222 alreadyImported: false,
223 },
224 ]);
225 }),
226 );
227
228 it.effect("groups Codex rollouts by cwd across date directories", () =>
229 Effect.gen(function* () {
230 const path = yield* Path.Path;
231 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
232 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
233 const workspace = yield* makeTempDir("t3code-workspace-");
234 const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
235
236 const rollout = (year: string, month: string, day: string, name: string) =>
237 path.join(codexHomePath, "sessions", year, month, day, name);
238
239 yield* writeTranscript({
240 filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"),
241 contents: codexRolloutLine(workspace),
242 mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"),
243 });
244 yield* writeTranscript({
245 filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"),
246 contents: codexRolloutLine(workspace),
247 mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"),
248 });
249 yield* writeTranscript({
250 filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"),
251 contents: codexRolloutLine(otherWorkspace),
252 mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"),
253 });
254
255 const result = yield* runScan({ claudeHomePath, codexHomePath });
256
257 expect(result.candidates).toEqual([
258 {
259 path: otherWorkspace,
260 title: path.basename(otherWorkspace),
261 sources: ["codex"],
262 threadCount: 1,
263 lastActiveAt: "2026-02-09T11:00:00.000Z",
264 alreadyImported: false,
265 },
266 {
267 path: workspace,
268 title: path.basename(workspace),
269 sources: ["codex"],
270 threadCount: 2,
271 lastActiveAt: "2026-02-09T10:00:00.000Z",
272 alreadyImported: false,
273 },
274 ]);
275 }),
276 );
277
278 it.effect.each(["claudeAgent", "codex"] as const)(
279 "does not open a non-file %s transcript",
280 (source) =>
281 Effect.gen(function* () {
282 const path = yield* Path.Path;
283 const fileSystem = yield* FileSystem.FileSystem;
284 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
285 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
286 const transcriptPath =
287 source === "claudeAgent"
288 ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl")
289 : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl");
290 yield* fileSystem.makeDirectory(transcriptPath, { recursive: true });
291
292 let transcriptOpenCount = 0;
293 const simulatedFileSystem = FileSystem.FileSystem.of({
294 ...fileSystem,
295 open: (filePath, options) => {
296 if (filePath === transcriptPath) transcriptOpenCount += 1;
297 return fileSystem.open(filePath, options);
298 },
299 });
300
301 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
302 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
303 );
304
305 expect(result.candidates).toEqual([]);
306 expect(transcriptOpenCount).toBe(0);
307 }),
308 );
309
310 it.effect.each(["claudeAgent", "codex"] as const)(
311 "stops %s directory reads at the discovery operation budget",
312 (source) =>
313 Effect.gen(function* () {
314 const path = yield* Path.Path;
315 const fileSystem = yield* FileSystem.FileSystem;
316 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
317 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
318 const discoveryRoot =
319 source === "claudeAgent"
320 ? path.join(claudeHomePath, "projects")
321 : path.join(codexHomePath, "sessions");
322 const emptyDirectories = Array.from(
323 { length: 20_001 },
324 (_, index) => `empty-${index.toString().padStart(5, "0")}`,
325 );
326 let directoryReadCount = 0;
327 const simulatedFileSystem = FileSystem.FileSystem.of({
328 ...fileSystem,
329 readDirectory: (directory, options) => {
330 if (directory === discoveryRoot) {
331 directoryReadCount += 1;
332 return Effect.succeed(emptyDirectories);
333 }
334 if (path.dirname(directory) === discoveryRoot) {
335 directoryReadCount += 1;
336 return Effect.succeed([]);
337 }
338 return fileSystem.readDirectory(directory, options);
339 },
340 });
341
342 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
343 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
344 );
345
346 expect(result.candidates).toEqual([]);
347 expect(directoryReadCount).toBe(20_000);
348 }),
349 );
350
351 it.effect("merges the same cwd seen by both agents and flags imported projects", () =>
352 Effect.gen(function* () {
353 const path = yield* Path.Path;
354 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
355 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
356 const workspace = yield* makeTempDir("t3code-workspace-");
357
358 yield* writeTranscript({
359 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
360 contents: claudeSessionLine(workspace),
361 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
362 });
363 yield* writeTranscript({
364 filePath: path.join(
365 codexHomePath,
366 "sessions",
367 "2026",
368 "04",
369 "01",
370 "rollout-2026-04-01T09-00-00-aaa.jsonl",
371 ),
372 contents: codexRolloutLine(workspace),
373 mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"),
374 });
375
376 const result = yield* runScan({
377 claudeHomePath,
378 codexHomePath,
379 importedWorkspaceRoots: [workspace],
380 });
381
382 expect(result.candidates).toEqual([
383 {
384 path: workspace,
385 title: path.basename(workspace),
386 projectId: ProjectId.make("project-1"),
387 sources: ["claudeAgent", "codex"],
388 threadCount: 2,
389 lastActiveAt: "2026-04-01T09:00:00.000Z",
390 alreadyImported: true,
391 },
392 ]);
393 }),
394 );
395
396 it.effect("returns the imported project ID through a realpath alias", () =>
397 Effect.gen(function* () {
398 const path = yield* Path.Path;
399 const fileSystem = yield* FileSystem.FileSystem;
400 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
401 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
402 const workspace = yield* makeTempDir("t3code-workspace-");
403 const linkParent = yield* makeTempDir("t3code-scanner-links-");
404 const workspaceAlias = path.join(linkParent, "workspace-alias");
405 yield* fileSystem.symlink(workspace, workspaceAlias);
406
407 yield* writeTranscript({
408 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
409 contents: claudeSessionLine(workspaceAlias),
410 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
411 });
412
413 const result = yield* runScan({
414 claudeHomePath,
415 codexHomePath,
416 importedWorkspaceRoots: [workspace],
417 });
418
419 expect(result.candidates[0]).toMatchObject({
420 path: workspace,
421 projectId: ProjectId.make("project-1"),
422 alreadyImported: true,
423 });
424 }),
425 );
426
427 it.effect("matches a persisted project alias to a transcript realpath", () =>
428 Effect.gen(function* () {
429 const path = yield* Path.Path;
430 const fileSystem = yield* FileSystem.FileSystem;
431 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
432 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
433 const workspace = yield* makeTempDir("t3code-workspace-");
434 const linkParent = yield* makeTempDir("t3code-scanner-links-");
435 const workspaceAlias = path.join(linkParent, "workspace-alias");
436 yield* fileSystem.symlink(workspace, workspaceAlias);
437
438 yield* writeTranscript({
439 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
440 contents: claudeSessionLine(workspace),
441 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
442 });
443
444 const result = yield* runScan({
445 claudeHomePath,
446 codexHomePath,
447 importedWorkspaceRoots: [workspaceAlias],
448 });
449
450 expect(result.candidates[0]).toMatchObject({
451 path: workspaceAlias,
452 projectId: ProjectId.make("project-1"),
453 alreadyImported: true,
454 });
455 }),
456 );
457
458 it.effect("merges case aliases and preserves the persisted project path", () =>
459 Effect.gen(function* () {
460 const path = yield* Path.Path;
461 const fileSystem = yield* FileSystem.FileSystem;
462 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
463 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
464 const workspace = yield* makeTempDir("t3code-workspace-");
465 const workspaceAlias = path.join(
466 path.dirname(workspace),
467 path.basename(workspace).toUpperCase(),
468 );
469
470 yield* writeTranscript({
471 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
472 contents: claudeSessionLine(workspaceAlias),
473 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
474 });
475 yield* writeTranscript({
476 filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"),
477 contents: codexRolloutLine(workspace),
478 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
479 });
480
481 const simulatedFileSystem = FileSystem.FileSystem.of({
482 ...fileSystem,
483 stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath),
484 });
485 const result = yield* runScan({
486 claudeHomePath,
487 codexHomePath,
488 importedWorkspaceRoots: [workspace],
489 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
490
491 expect(result.candidates).toEqual([
492 {
493 path: workspace,
494 title: path.basename(workspace),
495 projectId: ProjectId.make("project-1"),
496 sources: ["claudeAgent", "codex"],
497 threadCount: 2,
498 lastActiveAt: "2026-01-02T00:00:00.000Z",
499 alreadyImported: true,
500 },
501 ]);
502 }),
503 );
504
505 it.effect("keeps case variants distinct when the filesystem identities differ", () =>
506 Effect.gen(function* () {
507 const path = yield* Path.Path;
508 const fileSystem = yield* FileSystem.FileSystem;
509 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
510 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
511 const backingUpper = yield* makeTempDir("t3code-backing-upper-");
512 const backingLower = yield* makeTempDir("t3code-backing-lower-");
513 const aliasParent = yield* makeTempDir("t3code-case-aliases-");
514 const upperWorkspace = path.join(aliasParent, "Repo");
515 const lowerWorkspace = path.join(aliasParent, "repo");
516
517 yield* writeTranscript({
518 filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"),
519 contents: claudeSessionLine(upperWorkspace),
520 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
521 });
522 yield* writeTranscript({
523 filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"),
524 contents: claudeSessionLine(lowerWorkspace),
525 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
526 });
527
528 const simulatedFileSystem = FileSystem.FileSystem.of({
529 ...fileSystem,
530 stat: (filePath) =>
531 fileSystem.stat(
532 filePath === upperWorkspace
533 ? backingUpper
534 : filePath === lowerWorkspace
535 ? backingLower
536 : filePath,
537 ),
538 });
539 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
540 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
541 );
542
543 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
544 upperWorkspace,
545 lowerWorkspace,
546 ]);
547 }),
548 );
549
550 it.effect("uses explicit provider instance homes instead of overridden legacy homes", () =>
551 Effect.gen(function* () {
552 const path = yield* Path.Path;
553 const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
554 const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
555 const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-");
556 const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-");
557 const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-");
558 const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
559 const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");
560
561 yield* writeTranscript({
562 filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"),
563 contents: claudeSessionLine(legacyWorkspace),
564 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
565 });
566 yield* writeTranscript({
567 filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"),
568 contents: claudeSessionLine(claudeWorkspace),
569 mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"),
570 });
571 yield* writeTranscript({
572 filePath: path.join(
573 codexInstanceHome,
574 "sessions",
575 "2026",
576 "03",
577 "01",
578 "rollout-instance.jsonl",
579 ),
580 contents: codexRolloutLine(codexWorkspace),
581 mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"),
582 });
583
584 const result = yield* runScan({
585 claudeHomePath,
586 codexHomePath,
587 providerInstances: {
588 [ProviderInstanceId.make("claudeAgent")]: {
589 driver: ProviderDriverKind.make("claudeAgent"),
590 config: { homePath: claudeInstanceHome },
591 },
592 [ProviderInstanceId.make("codex")]: {
593 driver: ProviderDriverKind.make("codex"),
594 config: { homePath: codexInstanceHome },
595 },
596 },
597 });
598
599 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
600 codexWorkspace,
601 claudeWorkspace,
602 ]);
603 }),
604 );
605
606 it.effect("scans each distinct home across multiple instances once", () =>
607 Effect.gen(function* () {
608 const path = yield* Path.Path;
609 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
610 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
611 const otherCodexHome = yield* makeTempDir("t3code-codex-other-");
612 const workspace = yield* makeTempDir("t3code-workspace-");
613 const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
614
615 for (const [home, cwd] of [
616 [codexHomePath, workspace],
617 [otherCodexHome, otherWorkspace],
618 ] as const) {
619 yield* writeTranscript({
620 filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"),
621 contents: codexRolloutLine(cwd),
622 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
623 });
624 }
625
626 const result = yield* runScan({
627 claudeHomePath,
628 codexHomePath,
629 providerInstances: {
630 [ProviderInstanceId.make("codex-personal")]: {
631 driver: ProviderDriverKind.make("codex"),
632 config: { homePath: codexHomePath },
633 },
634 [ProviderInstanceId.make("codex-work")]: {
635 driver: ProviderDriverKind.make("codex"),
636 config: { homePath: otherCodexHome },
637 },
638 },
639 });
640
641 expect(result.candidates).toHaveLength(2);
642 expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]);
643 expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual(
644 [workspace, otherWorkspace].sort(),
645 );
646 }),
647 );
648
649 it.effect("honors provider instance home directory environment variables", () =>
650 Effect.gen(function* () {
651 const path = yield* Path.Path;
652 const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
653 const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
654 const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-");
655 const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-");
656 const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
657 const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");
658
659 yield* writeTranscript({
660 filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"),
661 contents: claudeSessionLine(claudeWorkspace),
662 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
663 });
664 yield* writeTranscript({
665 filePath: path.join(
666 codexEnvironmentHome,
667 "sessions",
668 "2026",
669 "01",
670 "01",
671 "rollout-session.jsonl",
672 ),
673 contents: codexRolloutLine(codexWorkspace),
674 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
675 });
676
677 const result = yield* runScan({
678 claudeHomePath,
679 codexHomePath,
680 providerInstances: {
681 [ProviderInstanceId.make("claudeAgent")]: {
682 driver: ProviderDriverKind.make("claudeAgent"),
683 environment: [
684 { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false },
685 ],
686 config: {},
687 },
688 [ProviderInstanceId.make("codex")]: {
689 driver: ProviderDriverKind.make("codex"),
690 environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }],
691 config: {},
692 },
693 },
694 });
695
696 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
697 codexWorkspace,
698 claudeWorkspace,
699 ]);
700 }),
701 );
702
703 it.effect("ignores invalid provider instances while scanning the remaining providers", () =>
704 Effect.gen(function* () {
705 const path = yield* Path.Path;
706 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
707 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
708 const workspace = yield* makeTempDir("t3code-workspace-");
709
710 yield* writeTranscript({
711 filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"),
712 contents: claudeSessionLine(workspace),
713 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
714 });
715
716 const result = yield* runScan({
717 claudeHomePath,
718 codexHomePath,
719 providerInstances: {
720 [ProviderInstanceId.make("codex")]: {
721 driver: ProviderDriverKind.make("codex"),
722 config: { homePath: 123 },
723 },
724 },
725 });
726
727 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
728 }),
729 );
730
731 it.effect("does not scan provider instances disabled by the envelope or config", () =>
732 Effect.gen(function* () {
733 const path = yield* Path.Path;
734 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
735 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
736 const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-");
737 const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-");
738 const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-");
739 const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-");
740
741 for (const [home, workspace, session] of [
742 [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"],
743 [configDisabledHome, configWorkspace, "config-disabled"],
744 ] as const) {
745 yield* writeTranscript({
746 filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`),
747 contents: codexRolloutLine(workspace),
748 mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"),
749 });
750 }
751
752 const result = yield* runScan({
753 claudeHomePath,
754 codexHomePath,
755 providerInstances: {
756 [ProviderInstanceId.make("codex-envelope-disabled")]: {
757 driver: ProviderDriverKind.make("codex"),
758 enabled: false,
759 config: { homePath: envelopeDisabledHome },
760 },
761 [ProviderInstanceId.make("codex-config-disabled")]: {
762 driver: ProviderDriverKind.make("codex"),
763 config: { enabled: false, homePath: configDisabledHome },
764 },
765 },
766 });
767
768 expect(result.candidates).toEqual([]);
769 }),
770 );
771
772 it.effect("ignores relative working directories from malformed transcripts", () =>
773 Effect.gen(function* () {
774 const path = yield* Path.Path;
775 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
776 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
777 const workspace = yield* makeTempDir("t3code-workspace-");
778
779 yield* writeTranscript({
780 filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"),
781 contents: claudeSessionLine(path.relative(path.resolve(), workspace)),
782 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
783 });
784
785 const result = yield* runScan({ claudeHomePath, codexHomePath });
786
787 expect(result.candidates).toEqual([]);
788 }),
789 );
790
791 it.effect("drops candidates whose directory no longer exists", () =>
792 Effect.gen(function* () {
793 const path = yield* Path.Path;
794 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
795 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
796
797 yield* writeTranscript({
798 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
799 contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")),
800 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
801 });
802
803 const result = yield* runScan({ claudeHomePath, codexHomePath });
804
805 expect(result.candidates).toEqual([]);
806 }),
807 );
808
809 it.effect("excludes the home directory, temporary root, and T3 data directory", () =>
810 Effect.gen(function* () {
811 const path = yield* Path.Path;
812 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
813 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
814 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
815 const workspace = yield* makeTempDir("t3code-workspace-");
816
817 for (const [index, cwd] of [
818 NodeOS.homedir(),
819 NodeOS.tmpdir(),
820 configBaseDir,
821 workspace,
822 ].entries()) {
823 yield* writeTranscript({
824 filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"),
825 contents: claudeSessionLine(cwd),
826 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index,
827 });
828 }
829
830 const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
831
832 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
833 }),
834 );
835
836 it.effect("excludes T3-managed worktree sandboxes", () =>
837 Effect.gen(function* () {
838 const path = yield* Path.Path;
839 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
840 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
841 const fileSystem = yield* FileSystem.FileSystem;
842
843 const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1");
844 yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
845 yield* writeTranscript({
846 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
847 contents: claudeSessionLine(worktreeCwd),
848 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
849 });
850
851 const result = yield* runScan({ claudeHomePath, codexHomePath });
852
853 expect(result.candidates).toEqual([]);
854 }),
855 );
856
857 it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>
858 Effect.gen(function* () {
859 const path = yield* Path.Path;
860 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
861 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
862 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
863 const fileSystem = yield* FileSystem.FileSystem;
864
865 // worktreesDir derives as `<baseDir>/worktrees`, and the temp base
866 // dir contains no `.t3` segment — only the config-based prefix match
867 // can exclude this one.
868 const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2");
869 yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
870 yield* writeTranscript({
871 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
872 contents: claudeSessionLine(worktreeCwd),
873 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
874 });
875
876 const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
877
878 expect(result.candidates).toEqual([]);
879 }),
880 );
881
882 it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () =>
883 Effect.gen(function* () {
884 const path = yield* Path.Path;
885 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
886 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
887 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
888 const linkParent = yield* makeTempDir("t3code-scanner-links-");
889 const fileSystem = yield* FileSystem.FileSystem;
890
891 // The recorded cwd is a symlink whose own spelling looks harmless;
892 // only its realpath reveals the managed sandbox.
893 const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3");
894 yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
895 const symlinkCwd = path.join(linkParent, "innocent-project");
896 yield* fileSystem.symlink(worktreeCwd, symlinkCwd);
897 yield* writeTranscript({
898 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
899 contents: claudeSessionLine(symlinkCwd),
900 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
901 });
902
903 const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
904
905 expect(result.candidates).toEqual([]);
906 }),
907 );
908
909 it.effect("finds the cwd on a later line when the first records carry none", () =>
910 Effect.gen(function* () {
911 const path = yield* Path.Path;
912 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
913 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
914 const workspace = yield* makeTempDir("t3code-workspace-");
915
916 // Claude transcripts often open with records that have no cwd.
917 const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`;
918 yield* writeTranscript({
919 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
920 contents,
921 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
922 });
923
924 const result = yield* runScan({ claudeHomePath, codexHomePath });
925
926 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
927 }),
928 );
929
930 it.effect("reads a complete transcript record at the exact chunk boundary", () =>
931 Effect.gen(function* () {
932 const path = yield* Path.Path;
933 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
934 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
935 const workspace = yield* makeTempDir("t3code-workspace-");
936 const record = claudeSessionLine(workspace).split("\n")[0]!;
937 const prefix = '{"padding":"';
938 const suffix = `",${record.slice(1)}`;
939 const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`;
940
941 yield* writeTranscript({
942 filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"),
943 contents,
944 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
945 });
946
947 const result = yield* runScan({ claudeHomePath, codexHomePath });
948
949 expect(contents).toHaveLength(32 * 1024);
950 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
951 }),
952 );
953
954 it.effect("finds session metadata after a first record larger than one chunk", () =>
955 Effect.gen(function* () {
956 const path = yield* Path.Path;
957 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
958 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
959 const workspace = yield* makeTempDir("t3code-workspace-");
960 const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`;
961
962 yield* writeTranscript({
963 filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"),
964 contents: `${history}${claudeSessionLine(workspace)}`,
965 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
966 });
967
968 const result = yield* runScan({ claudeHomePath, codexHomePath });
969
970 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
971 }),
972 );
973
974 it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) =>
975 Effect.gen(function* () {
976 const path = yield* Path.Path;
977 const fileSystem = yield* FileSystem.FileSystem;
978 const claudeHomePath = yield* makeTempDir("t3code-metadata-home-");
979 const secondHome = yield* makeTempDir("t3code-metadata-second-");
980 const codexHomePath = yield* makeTempDir("t3code-metadata-codex-");
981 const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-");
982 const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-");
983 const directories = [
984 path.join(claudeHomePath, "projects", "p"),
985 path.join(secondHome, "projects", "p"),
986 ];
987 const templates = directories.map((directory) => path.join(directory, "template.jsonl"));
988 for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) {
989 const record = encodeTranscriptRecord({ cwd: workspace });
990 yield* writeTranscript({
991 filePath: templates[index]!,
992 contents:
993 " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record,
994 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000,
995 });
996 }
997 const resolveFile = (filePath: string) => {
998 const index = directories.indexOf(path.dirname(filePath));
999 return index === -1 ? filePath : templates[index]!;
1000 };
1001 let reservedBytes = 0;
1002 let opens = 0;
1003 const requests: number[] = [];
1004 const observedFileSystem = FileSystem.FileSystem.of({
1005 ...fileSystem,
1006 readDirectory: (directory, options) => {
1007 const index = directories.indexOf(directory);
1008 return index === -1
1009 ? fileSystem.readDirectory(directory, options)
1010 : Effect.succeed(
1011 Array.from(
1012 { length: index === 0 ? 32 : count - 32 },
1013 (_, item) => `session-${item}.jsonl`,
1014 ),
1015 );
1016 },
1017 stat: (filePath) => fileSystem.stat(resolveFile(filePath)),
1018 open: (filePath, options) => {
1019 if (!directories.includes(path.dirname(filePath)))
1020 return fileSystem.open(filePath, options);
1021 opens += 1;
1022 return fileSystem.open(resolveFile(filePath), options).pipe(
1023 Effect.map((file) => ({
1024 ...file,
1025 stat: file.stat,
1026 readAlloc: (size: FileSystem.SizeInput) => {
1027 reservedBytes += Number(size);
1028 requests.push(Number(size));
1029 return file.readAlloc(size);
1030 },
1031 })),
1032 );
1033 },
1034 });
1035 const result = yield* runScan({
1036 claudeHomePath,
1037 codexHomePath,
1038 providerInstances: {
1039 [ProviderInstanceId.make("claude-work")]: {
1040 driver: ProviderDriverKind.make("claudeAgent"),
1041 config: { homePath: secondHome },
1042 },
1043 },
1044 }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem));
1045 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
1046 firstWorkspace,
1047 secondWorkspace,
1048 ]);
1049 expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]);
1050 expect(result.truncated).toBe(count === 65 ? true : undefined);
1051 expect(opens).toBe(64);
1052 expect(reservedBytes).toBe(64 * 1024 * 1024);
1053 expect(requests[0]).toBe(8 * 1024);
1054 expect(Math.max(...requests)).toBe(8 * 1024);
1055 }),
1056 );
1057
1058 it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) =>
1059 Effect.gen(function* () {
1060 const path = yield* Path.Path;
1061 const fileSystem = yield* FileSystem.FileSystem;
1062 const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-");
1063 const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-");
1064 const workspace = yield* makeTempDir("t3code-short-metadata-project-");
1065 const directory = path.join(claudeHomePath, "projects", "p");
1066 const template = path.join(directory, "template.jsonl");
1067 const record = encodeTranscriptRecord({ cwd: workspace });
1068 const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record;
1069 const bytes = new TextEncoder().encode(contents);
1070 yield* writeTranscript({
1071 filePath: template,
1072 contents,
1073 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1074 });
1075 let operations = 0;
1076 const observedFileSystem = FileSystem.FileSystem.of({
1077 ...fileSystem,
1078 readDirectory: (target, options) =>
1079 target === directory
1080 ? Effect.succeed(
1081 Array.from({ length: count }, (_, index) => `session-${index}.jsonl`),
1082 )
1083 : fileSystem.readDirectory(target, options),
1084 stat: (filePath) =>
1085 fileSystem.stat(path.dirname(filePath) === directory ? template : filePath),
1086 open: (filePath, options) => {
1087 if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options);
1088 operations += 1;
1089 let offset = 0;
1090 return fileSystem.open(template, options).pipe(
1091 Effect.map((file) => ({
1092 ...file,
1093 stat: file.stat,
1094 readAlloc: () =>
1095 Effect.sync(() => {
1096 operations += 1;
1097 if (offset === bytes.length) return Option.none<Uint8Array>();
1098 return Option.some(bytes.subarray(offset, ++offset));
1099 }),
1100 })),
1101 );
1102 },
1103 });
1104 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
1105 Effect.provideService(FileSystem.FileSystem, observedFileSystem),
1106 );
1107 expect(operations).toBe(20_000);
1108 expect(result.candidates[0]?.threadCount).toBe(50);
1109 expect(result.truncated).toBe(count === 51 ? true : undefined);
1110 }),
1111 );
1112
1113 it.effect("bounds malformed metadata records without excluding another account", () =>
1114 Effect.gen(function* () {
1115 const path = yield* Path.Path;
1116 const fileSystem = yield* FileSystem.FileSystem;
1117 const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-");
1118 const secondHome = yield* makeTempDir("t3code-record-metadata-second-");
1119 const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-");
1120 const workspace = yield* makeTempDir("t3code-record-metadata-project-");
1121 const directory = path.join(claudeHomePath, "projects", "p");
1122 const template = path.join(directory, "template.jsonl");
1123 yield* writeTranscript({
1124 filePath: template,
1125 contents: "x\n".repeat(1_001),
1126 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
1127 });
1128 yield* writeTranscript({
1129 filePath: path.join(secondHome, "projects", "p", "session.jsonl"),
1130 contents: encodeTranscriptRecord({ cwd: workspace }),
1131 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1132 });
1133 let malformedOpens = 0;
1134 const observedFileSystem = FileSystem.FileSystem.of({
1135 ...fileSystem,
1136 readDirectory: (target, options) =>
1137 target === directory
1138 ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`))
1139 : fileSystem.readDirectory(target, options),
1140 stat: (filePath) =>
1141 fileSystem.stat(path.dirname(filePath) === directory ? template : filePath),
1142 open: (filePath, options) => {
1143 if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options);
1144 malformedOpens += 1;
1145 return fileSystem.open(template, options);
1146 },
1147 });
1148 const result = yield* runScan({
1149 claudeHomePath,
1150 codexHomePath,
1151 providerInstances: {
1152 [ProviderInstanceId.make("claude-work")]: {
1153 driver: ProviderDriverKind.make("claudeAgent"),
1154 config: { homePath: secondHome },
1155 },
1156 },
1157 }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem));
1158 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1159 expect(malformedOpens).toBe(100);
1160 expect(result.truncated).toBe(true);
1161 }),
1162 );
1163
1164 it.effect.each([19_999, 20_000])(
1165 "reports unfinished directory work for %s project directories",
1166 (count) =>
1167 Effect.gen(function* () {
1168 const path = yield* Path.Path;
1169 const fileSystem = yield* FileSystem.FileSystem;
1170 const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-");
1171 const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-");
1172 const projectsDir = path.join(claudeHomePath, "projects");
1173 let reads = 0;
1174 const observedFileSystem = FileSystem.FileSystem.of({
1175 ...fileSystem,
1176 readDirectory: (directory, options) => {
1177 if (directory === projectsDir) {
1178 reads += 1;
1179 return Effect.succeed(
1180 Array.from({ length: count }, (_, index) => `project-${index}`),
1181 );
1182 }
1183 if (path.dirname(directory) === projectsDir) {
1184 reads += 1;
1185 return Effect.succeed([]);
1186 }
1187 return fileSystem.readDirectory(directory, options);
1188 },
1189 });
1190 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
1191 Effect.provideService(FileSystem.FileSystem, observedFileSystem),
1192 );
1193 expect(reads).toBe(20_000);
1194 expect(result.candidates).toEqual([]);
1195 expect(result.truncated).toBe(count === 20_000 ? true : undefined);
1196 }),
1197 );
1198
1199 it.effect("skips malformed transcripts without failing the scan", () =>
1200 Effect.gen(function* () {
1201 const path = yield* Path.Path;
1202 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1203 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1204 const workspace = yield* makeTempDir("t3code-workspace-");
1205
1206 yield* writeTranscript({
1207 filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"),
1208 contents: "not json at all\n",
1209 mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"),
1210 });
1211 // Valid JSON, but no cwd anywhere in the record.
1212 yield* writeTranscript({
1213 filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"),
1214 contents: `{"type":"summary"}\n`,
1215 mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"),
1216 });
1217 yield* writeTranscript({
1218 filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"),
1219 contents: claudeSessionLine(workspace),
1220 mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"),
1221 });
1222
1223 const result = yield* runScan({ claudeHomePath, codexHomePath });
1224
1225 expect(result.candidates).toEqual([
1226 {
1227 path: workspace,
1228 title: path.basename(workspace),
1229 sources: ["claudeAgent"],
1230 threadCount: 1,
1231 lastActiveAt: "2026-05-03T00:00:00.000Z",
1232 alreadyImported: false,
1233 },
1234 ]);
1235 }),
1236 );
1237
1238 it.effect("returns an empty result when neither home directory exists", () =>
1239 Effect.gen(function* () {
1240 const path = yield* Path.Path;
1241 const root = yield* makeTempDir("t3code-missing-homes-");
1242
1243 const result = yield* runScan({
1244 claudeHomePath: path.join(root, "no-claude"),
1245 codexHomePath: path.join(root, "no-codex"),
1246 });
1247
1248 expect(result.candidates).toEqual([]);
1249 expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
1250 }),
1251 );
1252 });
1253
1254 describe("recentThreads", () => {
1255 it.effect.each([false, true])(
1256 "counts terminal newlines correctly with record overflow=%s",
1257 (overflow) =>
1258 Effect.gen(function* () {
1259 const path = yield* Path.Path;
1260 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1261 yield* TestClock.setTime(nowMs);
1262 const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-");
1263 const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-");
1264 const workspace = yield* makeTempDir("t3code-record-limit-project-");
1265 const directory = path.join(codexHomePath, "sessions", "2026", "08", "24");
1266 yield* writeTranscript({
1267 filePath: path.join(directory, "rollout-records.jsonl"),
1268 contents: makeRecordLimitTranscript(workspace, overflow),
1269 mtimeMs: nowMs,
1270 });
1271 yield* writeTranscript({
1272 filePath: path.join(directory, "rollout-older.jsonl"),
1273 contents: [
1274 encodeTranscriptRecord({
1275 type: "session_meta",
1276 payload: { id: "older-session", cwd: workspace },
1277 }),
1278 encodeTranscriptRecord({
1279 type: "event_msg",
1280 payload: { type: "user_message", message: "Older prompt" },
1281 }),
1282 ].join("\n"),
1283 mtimeMs: nowMs - 1_000,
1284 });
1285 const outcomes = yield* runRecentThreadOutcomes({
1286 claudeHomePath,
1287 codexHomePath,
1288 workspaceRoot: workspace,
1289 });
1290 expect(outcomes.map((outcome) => outcome._tag)).toEqual(
1291 overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"],
1292 );
1293 expect(
1294 outcomes.flatMap((outcome) =>
1295 outcome._tag === "Importable"
1296 ? outcome.thread.messages.map((message) => message.text)
1297 : [],
1298 ),
1299 ).toEqual([overflow ? "Older prompt" : "First prompt"]);
1300 }),
1301 );
1302
1303 it.effect("imports recent Claude and Codex sessions for the selected project only", () =>
1304 Effect.gen(function* () {
1305 const path = yield* Path.Path;
1306 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1307 yield* TestClock.setTime(nowMs);
1308 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1309 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1310 const workspace = yield* makeTempDir("t3code-workspace-");
1311 const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
1312
1313 const claudeTranscript = (cwd: string, sessionId: string) =>
1314 `${JSON.stringify({
1315 type: "user",
1316 cwd,
1317 sessionId,
1318 timestamp: "2026-08-23T12:00:00.000Z",
1319 message: { role: "user", content: "Fix the project" },
1320 })}\n${JSON.stringify({
1321 type: "assistant",
1322 sessionId,
1323 timestamp: "2026-08-23T12:01:00.000Z",
1324 message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
1325 })}\n`;
1326
1327 yield* writeTranscript({
1328 filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"),
1329 contents: claudeTranscript(workspace, "claude-recent"),
1330 mtimeMs: nowMs - 24 * 60 * 60 * 1000,
1331 });
1332 yield* writeTranscript({
1333 filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"),
1334 contents: claudeTranscript(workspace, "claude-old"),
1335 mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000,
1336 });
1337 yield* writeTranscript({
1338 filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"),
1339 contents: claudeTranscript(otherWorkspace, "claude-other"),
1340 mtimeMs: nowMs - 24 * 60 * 60 * 1000,
1341 });
1342 yield* writeTranscript({
1343 filePath: path.join(
1344 codexHomePath,
1345 "sessions",
1346 "2026",
1347 "08",
1348 "24",
1349 "rollout-codex-recent.jsonl",
1350 ),
1351 contents: [
1352 encodeTranscriptRecord({
1353 type: "session_meta",
1354 payload: { id: "codex-recent", cwd: workspace },
1355 }),
1356 encodeTranscriptRecord({
1357 type: "event_msg",
1358 timestamp: "2026-08-24T10:00:00.000Z",
1359 payload: { type: "user_message", message: "Review this code" },
1360 }),
1361 encodeTranscriptRecord({
1362 type: "response_item",
1363 timestamp: "2026-08-24T10:01:00.000Z",
1364 payload: {
1365 type: "message",
1366 role: "assistant",
1367 content: [{ type: "output_text", text: "Looks good" }],
1368 },
1369 }),
1370 ].join("\n"),
1371 mtimeMs: nowMs - 60 * 60 * 1000,
1372 });
1373
1374 const threads = yield* runRecentThreads({
1375 claudeHomePath,
1376 codexHomePath,
1377 workspaceRoot: workspace,
1378 });
1379
1380 expect(threads.map((thread) => thread.providerSessionId)).toEqual([
1381 "codex-recent",
1382 "claude-recent",
1383 ]);
1384 expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([
1385 ["Review this code", "Looks good"],
1386 ["Fix the project", "Done"],
1387 ]);
1388 }),
1389 );
1390
1391 it.effect("imports history recorded with a case alias", () =>
1392 Effect.gen(function* () {
1393 const path = yield* Path.Path;
1394 const fileSystem = yield* FileSystem.FileSystem;
1395 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1396 yield* TestClock.setTime(nowMs);
1397 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1398 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1399 const workspace = yield* makeTempDir("t3code-workspace-");
1400 const workspaceAlias = path.join(
1401 path.dirname(workspace),
1402 path.basename(workspace).toUpperCase(),
1403 );
1404
1405 yield* writeTranscript({
1406 filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"),
1407 contents: [
1408 encodeTranscriptRecord({
1409 type: "user",
1410 cwd: workspaceAlias,
1411 sessionId: "case-session",
1412 timestamp: "2026-08-24T10:00:00.000Z",
1413 message: { role: "user", content: "Import case alias history" },
1414 }),
1415 encodeTranscriptRecord({
1416 type: "assistant",
1417 sessionId: "case-session",
1418 timestamp: "2026-08-24T10:01:00.000Z",
1419 message: { role: "assistant", content: "Imported" },
1420 }),
1421 ].join("\n"),
1422 mtimeMs: nowMs,
1423 });
1424
1425 const simulatedFileSystem = FileSystem.FileSystem.of({
1426 ...fileSystem,
1427 stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath),
1428 });
1429 const threads = yield* runRecentThreads({
1430 claudeHomePath,
1431 codexHomePath,
1432 workspaceRoot: workspace,
1433 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
1434
1435 expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]);
1436 }),
1437 );
1438
1439 it.effect("keeps the provider instance that owns a custom session home", () =>
1440 Effect.gen(function* () {
1441 const path = yield* Path.Path;
1442 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1443 yield* TestClock.setTime(nowMs);
1444 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1445 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1446 const customHome = yield* makeTempDir("t3code-codex-custom-");
1447 const workspace = yield* makeTempDir("t3code-workspace-");
1448
1449 yield* writeTranscript({
1450 filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"),
1451 contents: [
1452 encodeTranscriptRecord({
1453 type: "session_meta",
1454 payload: { id: "custom-session", cwd: workspace },
1455 }),
1456 encodeTranscriptRecord({
1457 type: "event_msg",
1458 payload: { type: "user_message", message: "Use my work account" },
1459 }),
1460 ].join("\n"),
1461 mtimeMs: nowMs,
1462 });
1463
1464 const threads = yield* runRecentThreads({
1465 claudeHomePath,
1466 codexHomePath,
1467 workspaceRoot: workspace,
1468 providerInstances: {
1469 [ProviderInstanceId.make("codex-work")]: {
1470 driver: ProviderDriverKind.make("codex"),
1471 config: { homePath: customHome },
1472 },
1473 },
1474 });
1475
1476 expect(threads[0]?.providerInstanceId).toBe("codex-work");
1477 }),
1478 );
1479
1480 it.effect("suppresses duplicate session copies without reporting a skipped import", () =>
1481 Effect.gen(function* () {
1482 const path = yield* Path.Path;
1483 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1484 yield* TestClock.setTime(nowMs);
1485 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1486 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1487 const workspace = yield* makeTempDir("t3code-workspace-");
1488 const contents = [
1489 encodeTranscriptRecord({
1490 type: "session_meta",
1491 payload: { id: "copied-session", cwd: workspace },
1492 }),
1493 encodeTranscriptRecord({
1494 type: "event_msg",
1495 payload: { type: "user_message", message: "Import this session once" },
1496 }),
1497 ].join("\n");
1498
1499 for (const [name, mtimeMs] of [
1500 ["rollout-copy-a.jsonl", nowMs],
1501 ["rollout-copy-b.jsonl", nowMs - 1],
1502 ] as const) {
1503 yield* writeTranscript({
1504 filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name),
1505 contents,
1506 mtimeMs,
1507 });
1508 }
1509
1510 const outcomes = yield* runRecentThreadOutcomes({
1511 claudeHomePath,
1512 codexHomePath,
1513 workspaceRoot: workspace,
1514 });
1515
1516 expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]);
1517 expect(outcomes[0]).toMatchObject({
1518 _tag: "Importable",
1519 thread: { providerSessionId: "copied-session" },
1520 });
1521 }),
1522 );
1523
1524 it.effect("shares a 64 MiB full-read budget across providers without hiding projects", () =>
1525 Effect.gen(function* () {
1526 const path = yield* Path.Path;
1527 const fileSystem = yield* FileSystem.FileSystem;
1528 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1529 yield* TestClock.setTime(nowMs);
1530 const claudeHomePath = yield* makeTempDir("t3code-budget-claude-");
1531 const codexHomePath = yield* makeTempDir("t3code-budget-codex-");
1532 const workspace = yield* makeTempDir("t3code-budget-workspace-");
1533 const transcriptPaths = new Set<string>();
1534 for (const [index, source] of [
1535 "codex",
1536 "claudeAgent",
1537 "codex",
1538 "claudeAgent",
1539 "codex",
1540 ].entries()) {
1541 const sessionId = `budget-session-${index}`;
1542 const filePath =
1543 source === "codex"
1544 ? path.join(
1545 codexHomePath,
1546 "sessions",
1547 "2026",
1548 "08",
1549 "24",
1550 `rollout-${sessionId}.jsonl`,
1551 )
1552 : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`);
1553 const contents =
1554 source === "codex"
1555 ? [
1556 encodeTranscriptRecord({
1557 type: "session_meta",
1558 payload: { id: sessionId, cwd: workspace },
1559 }),
1560 encodeTranscriptRecord({
1561 type: "event_msg",
1562 payload: { type: "user_message", message: "Imported prompt" },
1563 }),
1564 ].join("\n")
1565 : encodeTranscriptRecord({
1566 type: "user",
1567 cwd: workspace,
1568 sessionId,
1569 message: { content: "Imported prompt" },
1570 });
1571 transcriptPaths.add(filePath);
1572 yield* writeTranscript({
1573 filePath,
1574 contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "),
1575 mtimeMs: nowMs - index * 1_000,
1576 });
1577 }
1578
1579 const opens = new Map<string, number>();
1580 let fullReadBytes = 0;
1581 const trackedFileSystem = FileSystem.FileSystem.of({
1582 ...fileSystem,
1583 open: (filePath, options) => {
1584 const count = (opens.get(filePath) ?? 0) + 1;
1585 opens.set(filePath, count);
1586 return fileSystem.open(filePath, options).pipe(
1587 Effect.map((file) =>
1588 !transcriptPaths.has(filePath) || count === 1
1589 ? file
1590 : {
1591 ...file,
1592 stat: file.stat,
1593 readAlloc: (size: FileSystem.SizeInput) =>
1594 file.readAlloc(size).pipe(
1595 Effect.tap((chunk) =>
1596 Effect.sync(() => {
1597 if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength;
1598 }),
1599 ),
1600 ),
1601 },
1602 ),
1603 );
1604 },
1605 });
1606 const outcomes = yield* Effect.gen(function* () {
1607 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
1608 const scan = yield* scanner.scan;
1609 expect(scan.candidates[0]?.threadCount).toBe(5);
1610 return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
1611 }).pipe(
1612 Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })),
1613 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1614 );
1615
1616 expect(outcomes.map((outcome) => outcome._tag)).toEqual([
1617 "Importable",
1618 "Importable",
1619 "Importable",
1620 "Importable",
1621 "Skipped",
1622 ]);
1623 expect(fullReadBytes).toBe(64 * 1024 * 1024);
1624 }),
1625 );
1626
1627 it.effect("skips excessive records without blocking an older valid transcript", () =>
1628 Effect.gen(function* () {
1629 const path = yield* Path.Path;
1630 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1631 yield* TestClock.setTime(nowMs);
1632 const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-");
1633 const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-");
1634 const workspace = yield* makeTempDir("t3code-record-budget-workspace-");
1635 for (const [sessionId, padding, mtimeMs] of [
1636 ["excessive", "\n".repeat(100_001), nowMs],
1637 ["older", "", nowMs - 1_000],
1638 ] as const) {
1639 yield* writeTranscript({
1640 filePath: path.join(
1641 codexHomePath,
1642 "sessions",
1643 "2026",
1644 "08",
1645 "24",
1646 `rollout-${sessionId}.jsonl`,
1647 ),
1648 contents:
1649 [
1650 encodeTranscriptRecord({
1651 type: "session_meta",
1652 payload: { id: sessionId, cwd: workspace },
1653 }),
1654 encodeTranscriptRecord({
1655 type: "event_msg",
1656 payload: { type: "user_message", message: "Imported prompt" },
1657 }),
1658 ].join("\n") + padding,
1659 mtimeMs,
1660 });
1661 }
1662 const outcomes = yield* runRecentThreadOutcomes({
1663 claudeHomePath,
1664 codexHomePath,
1665 workspaceRoot: workspace,
1666 });
1667 expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]);
1668 expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } });
1669 }),
1670 );
1671
1672 for (const source of ["claudeAgent", "codex"] as const) {
1673 for (const replacement of [
1674 "same root",
1675 "other root",
1676 "symlink alias",
1677 "other then same",
1678 ] as const) {
1679 it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)(
1680 `rechecks ${source} snapshot cwd after replacement with ${replacement}`,
1681 () =>
1682 Effect.gen(function* () {
1683 const path = yield* Path.Path;
1684 const fileSystem = yield* FileSystem.FileSystem;
1685 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1686 yield* TestClock.setTime(nowMs);
1687 const fixture = yield* makeTempDir("t3code-replaced-cwd-");
1688 const workspace = path.join(fixture, "original");
1689 const otherWorkspace = path.join(fixture, "other");
1690 const alias = path.join(fixture, "alias");
1691 const claudeHomePath = path.join(fixture, "claude");
1692 const codexHomePath = path.join(fixture, "codex");
1693 yield* fileSystem.makeDirectory(workspace);
1694 yield* fileSystem.makeDirectory(otherWorkspace);
1695 if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias);
1696 const filePath =
1697 source === "codex"
1698 ? path.join(
1699 codexHomePath,
1700 "sessions",
1701 "2026",
1702 "08",
1703 "24",
1704 "rollout-replaced.jsonl",
1705 )
1706 : path.join(claudeHomePath, "projects", "p", "replaced.jsonl");
1707 const makeContents = (cwd: string, text: string, laterCwd?: string) =>
1708 [
1709 ...(source === "codex"
1710 ? [
1711 { type: "session_meta", payload: { id: "replacement-session", cwd } },
1712 { type: "event_msg", payload: { type: "user_message", message: text } },
1713 ]
1714 : [
1715 {
1716 type: "user",
1717 cwd,
1718 sessionId: "replacement-session",
1719 message: { content: text },
1720 },
1721 ]),
1722 ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]),
1723 ]
1724 .map((record) => encodeTranscriptRecord(record))
1725 .join("\n");
1726 yield* writeTranscript({
1727 filePath,
1728 contents: makeContents(workspace, "Original prompt"),
1729 mtimeMs: nowMs,
1730 });
1731
1732 yield* Effect.gen(function* () {
1733 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
1734 const scan = yield* scanner.scan;
1735 expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1736 const replacementCwd =
1737 replacement === "symlink alias"
1738 ? alias
1739 : replacement === "same root"
1740 ? workspace
1741 : otherWorkspace;
1742 yield* fileSystem.remove(filePath);
1743 yield* writeTranscript({
1744 filePath,
1745 contents: makeContents(
1746 replacementCwd,
1747 "Replacement prompt",
1748 replacement === "other then same" ? workspace : undefined,
1749 ),
1750 mtimeMs: nowMs,
1751 });
1752 const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
1753 if (replacement === "same root" || replacement === "symlink alias") {
1754 expect(outcomes).toHaveLength(1);
1755 expect(outcomes[0]).toMatchObject({
1756 _tag: "Importable",
1757 thread: { messages: [{ text: "Replacement prompt" }] },
1758 });
1759 } else {
1760 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
1761 }
1762 }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })));
1763 }),
1764 );
1765 }
1766 }
1767
1768 it.effect("checks file identity and provider before skipping completed history", () =>
1769 Effect.gen(function* () {
1770 const path = yield* Path.Path;
1771 const fileSystem = yield* FileSystem.FileSystem;
1772 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1773 yield* TestClock.setTime(nowMs);
1774 const claudeHomePath = yield* makeTempDir("t3code-completed-claude-");
1775 const codexHomePath = yield* makeTempDir("t3code-completed-codex-");
1776 const workspace = yield* makeTempDir("t3code-completed-workspace-");
1777 const filePath = path.join(
1778 codexHomePath,
1779 "sessions",
1780 "2026",
1781 "08",
1782 "24",
1783 "rollout-replaced.jsonl",
1784 );
1785 const contents = (sessionId: string) =>
1786 [
1787 encodeTranscriptRecord({
1788 type: "session_meta",
1789 payload: { id: sessionId, cwd: workspace },
1790 }),
1791 encodeTranscriptRecord({
1792 type: "event_msg",
1793 payload: { type: "user_message", message: "Imported prompt" },
1794 }),
1795 ].join("\n");
1796 yield* writeTranscript({
1797 filePath,
1798 contents: contents("original-session"),
1799 mtimeMs: nowMs,
1800 });
1801
1802 yield* Effect.gen(function* () {
1803 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
1804 const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
1805 const imported = initial[0];
1806 expect(imported?._tag).toBe("Importable");
1807 if (imported?._tag !== "Importable") return;
1808 const completed = yield* scanner
1809 .recentThreads(workspace, [imported.source])
1810 .pipe(Stream.runCollect);
1811 expect(completed[0]?._tag).toBe("AlreadyImported");
1812 const wrongProvider = yield* scanner
1813 .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }])
1814 .pipe(Stream.runCollect);
1815 expect(wrongProvider[0]?._tag).toBe("Importable");
1816
1817 // Keep the old inode allocated while replacing the path with an equal-size file.
1818 yield* fileSystem.open(filePath);
1819 yield* fileSystem.remove(filePath);
1820 yield* writeTranscript({
1821 filePath,
1822 contents: contents("replaced-session"),
1823 mtimeMs: nowMs,
1824 });
1825 const replaced = yield* scanner
1826 .recentThreads(workspace, [imported.source])
1827 .pipe(Stream.runCollect);
1828 expect(replaced[0]).toMatchObject({
1829 _tag: "Importable",
1830 thread: { providerSessionId: "replaced-session" },
1831 source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs },
1832 });
1833 }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })));
1834 }),
1835 );
1836
1837 it.effect("reports an eligible transcript over 16 MiB as skipped", () =>
1838 Effect.gen(function* () {
1839 const path = yield* Path.Path;
1840 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1841 yield* TestClock.setTime(nowMs);
1842 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1843 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1844 const workspace = yield* makeTempDir("t3code-workspace-");
1845 const transcript = [
1846 encodeTranscriptRecord({
1847 type: "session_meta",
1848 payload: { id: "large-session", cwd: workspace },
1849 }),
1850 encodeTranscriptRecord({
1851 type: "event_msg",
1852 payload: { type: "user_message", message: "Import this large session" },
1853 }),
1854 ]
1855 .join("\n")
1856 .padEnd(16 * 1024 * 1024 + 1, " ");
1857 yield* writeTranscript({
1858 filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"),
1859 contents: transcript,
1860 mtimeMs: nowMs,
1861 });
1862
1863 const outcomes = yield* runRecentThreadOutcomes({
1864 claudeHomePath,
1865 codexHomePath,
1866 workspaceRoot: workspace,
1867 });
1868
1869 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
1870 }),
1871 );
1872
1873 it.effect("reports stat, read, and parse failures as skipped", () =>
1874 Effect.gen(function* () {
1875 const path = yield* Path.Path;
1876 const fileSystem = yield* FileSystem.FileSystem;
1877 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1878 yield* TestClock.setTime(nowMs);
1879 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1880 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1881 const workspace = yield* makeTempDir("t3code-workspace-");
1882 const missingPath = path.join(codexHomePath, "missing.jsonl");
1883 const transcriptPaths = {
1884 stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"),
1885 read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"),
1886 parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"),
1887 };
1888 const transcriptContents = (sessionId: string) =>
1889 [
1890 encodeTranscriptRecord({
1891 type: "session_meta",
1892 payload: { id: sessionId, cwd: workspace },
1893 }),
1894 encodeTranscriptRecord({
1895 type: "event_msg",
1896 payload: { type: "user_message", message: "Import this session" },
1897 }),
1898 ].join("\n");
1899
1900 yield* writeTranscript({
1901 filePath: transcriptPaths.stat,
1902 contents: transcriptContents("stat-session"),
1903 mtimeMs: nowMs,
1904 });
1905 yield* writeTranscript({
1906 filePath: transcriptPaths.read,
1907 contents: transcriptContents("read-session"),
1908 mtimeMs: nowMs,
1909 });
1910 yield* writeTranscript({
1911 filePath: transcriptPaths.parse,
1912 contents: encodeTranscriptRecord({
1913 type: "session_meta",
1914 payload: { id: "parse-session", cwd: workspace },
1915 }),
1916 mtimeMs: nowMs,
1917 });
1918
1919 let statCount = 0;
1920 let readOpenCount = 0;
1921 const simulatedFileSystem = FileSystem.FileSystem.of({
1922 ...fileSystem,
1923 stat: (filePath) => {
1924 if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath);
1925 statCount += 1;
1926 return fileSystem.stat(statCount === 1 ? filePath : missingPath);
1927 },
1928 open: (filePath, options) => {
1929 if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options);
1930 readOpenCount += 1;
1931 return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options);
1932 },
1933 });
1934
1935 const outcomes = yield* runRecentThreadOutcomes({
1936 claudeHomePath,
1937 codexHomePath,
1938 workspaceRoot: workspace,
1939 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
1940
1941 expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]);
1942 }),
1943 );
1944
1945 it.effect("does not reopen a transcript that becomes a non-file after discovery", () =>
1946 Effect.gen(function* () {
1947 const path = yield* Path.Path;
1948 const fileSystem = yield* FileSystem.FileSystem;
1949 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1950 yield* TestClock.setTime(nowMs);
1951 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1952 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1953 const workspace = yield* makeTempDir("t3code-workspace-");
1954 const nonFilePath = yield* makeTempDir("t3code-non-file-");
1955 const transcriptPath = path.join(
1956 codexHomePath,
1957 "sessions",
1958 "2026",
1959 "08",
1960 "24",
1961 "rollout-changed.jsonl",
1962 );
1963 yield* writeTranscript({
1964 filePath: transcriptPath,
1965 contents: [
1966 encodeTranscriptRecord({
1967 type: "session_meta",
1968 payload: { id: "changed-session", cwd: workspace },
1969 }),
1970 encodeTranscriptRecord({
1971 type: "event_msg",
1972 payload: { type: "user_message", message: "Do not import this session" },
1973 }),
1974 ].join("\n"),
1975 mtimeMs: nowMs,
1976 });
1977
1978 let transcriptStatCount = 0;
1979 let transcriptOpenCount = 0;
1980 const simulatedFileSystem = FileSystem.FileSystem.of({
1981 ...fileSystem,
1982 stat: (filePath) => {
1983 if (filePath !== transcriptPath) return fileSystem.stat(filePath);
1984 transcriptStatCount += 1;
1985 return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath);
1986 },
1987 open: (filePath, options) => {
1988 if (filePath === transcriptPath) transcriptOpenCount += 1;
1989 return fileSystem.open(filePath, options);
1990 },
1991 });
1992
1993 const outcomes = yield* runRecentThreadOutcomes({
1994 claudeHomePath,
1995 codexHomePath,
1996 workspaceRoot: workspace,
1997 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
1998
1999 expect(transcriptStatCount).toBe(2);
2000 expect(transcriptOpenCount).toBe(1);
2001 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
2002 }),
2003 );
2004
2005 it.effect("does not import a transcript dated after the current time", () =>
2006 Effect.gen(function* () {
2007 const path = yield* Path.Path;
2008 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2009 yield* TestClock.setTime(nowMs);
2010 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2011 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2012 const workspace = yield* makeTempDir("t3code-workspace-");
2013 yield* writeTranscript({
2014 filePath: path.join(
2015 codexHomePath,
2016 "sessions",
2017 "2026",
2018 "08",
2019 "24",
2020 "rollout-future.jsonl",
2021 ),
2022 contents: [
2023 encodeTranscriptRecord({
2024 type: "session_meta",
2025 payload: { id: "future-session", cwd: workspace },
2026 }),
2027 encodeTranscriptRecord({
2028 type: "event_msg",
2029 payload: { type: "user_message", message: "Future work" },
2030 }),
2031 ].join("\n"),
2032 mtimeMs: nowMs + 1,
2033 });
2034
2035 const outcomes = yield* runRecentThreadOutcomes({
2036 claudeHomePath,
2037 codexHomePath,
2038 workspaceRoot: workspace,
2039 });
2040
2041 expect(outcomes).toEqual([]);
2042 }),
2043 );
2044
2045 it.effect("skips growth during reading without exceeding the reserved bytes", () =>
2046 Effect.gen(function* () {
2047 const path = yield* Path.Path;
2048 const fileSystem = yield* FileSystem.FileSystem;
2049 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2050 yield* TestClock.setTime(nowMs);
2051 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2052 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2053 const workspace = yield* makeTempDir("t3code-workspace-");
2054 const transcriptPath = path.join(
2055 codexHomePath,
2056 "sessions",
2057 "2026",
2058 "08",
2059 "24",
2060 "rollout-growing.jsonl",
2061 );
2062 const contents = [
2063 encodeTranscriptRecord({
2064 type: "session_meta",
2065 payload: { id: "growing-session", cwd: workspace },
2066 }),
2067 encodeTranscriptRecord({
2068 type: "event_msg",
2069 payload: { type: "user_message", message: "Do not import a changing file" },
2070 }),
2071 ].join("\n");
2072 yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs });
2073 let transcriptOpenCount = 0;
2074 let fullReadBytes = 0;
2075 let grew = false;
2076 const simulatedFileSystem = FileSystem.FileSystem.of({
2077 ...fileSystem,
2078 open: (filePath, options) => {
2079 if (filePath !== transcriptPath) return fileSystem.open(filePath, options);
2080 transcriptOpenCount += 1;
2081 if (transcriptOpenCount === 1) return fileSystem.open(filePath, options);
2082 return fileSystem.open(filePath, options).pipe(
2083 Effect.map((file) => ({
2084 ...file,
2085 stat: file.stat,
2086 readAlloc: (size: FileSystem.SizeInput) =>
2087 file.readAlloc(size).pipe(
2088 Effect.tap((chunk) =>
2089 Effect.gen(function* () {
2090 if (chunk._tag === "None") return;
2091 fullReadBytes += chunk.value.byteLength;
2092 if (!grew) {
2093 grew = true;
2094 yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`);
2095 }
2096 }),
2097 ),
2098 ),
2099 })),
2100 );
2101 },
2102 });
2103
2104 const outcomes = yield* runRecentThreadOutcomes({
2105 claudeHomePath,
2106 codexHomePath,
2107 workspaceRoot: workspace,
2108 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
2109
2110 expect(transcriptOpenCount).toBe(2);
2111 expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength);
2112 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
2113 }),
2114 );
2115
2116 it.effect("skips a transcript that shrinks after its size check", () =>
2117 Effect.gen(function* () {
2118 const path = yield* Path.Path;
2119 const fileSystem = yield* FileSystem.FileSystem;
2120 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2121 yield* TestClock.setTime(nowMs);
2122 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2123 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2124 const workspace = yield* makeTempDir("t3code-workspace-");
2125 const transcriptPath = path.join(
2126 codexHomePath,
2127 "sessions",
2128 "2026",
2129 "08",
2130 "24",
2131 "rollout-shrinking.jsonl",
2132 );
2133 const shrunkPath = path.join(codexHomePath, "shrunk.jsonl");
2134 const contents = [
2135 encodeTranscriptRecord({
2136 type: "session_meta",
2137 payload: { id: "shrinking-session", cwd: workspace },
2138 }),
2139 encodeTranscriptRecord({
2140 type: "event_msg",
2141 payload: { type: "user_message", message: "Do not import a changing file" },
2142 }),
2143 ].join("\n");
2144 yield* writeTranscript({
2145 filePath: transcriptPath,
2146 contents: `${contents}\n${"padding".repeat(100)}`,
2147 mtimeMs: nowMs,
2148 });
2149 yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs });
2150
2151 let transcriptOpenCount = 0;
2152 const simulatedFileSystem = FileSystem.FileSystem.of({
2153 ...fileSystem,
2154 open: (filePath, options) => {
2155 if (filePath !== transcriptPath) return fileSystem.open(filePath, options);
2156 transcriptOpenCount += 1;
2157 return fileSystem.open(
2158 transcriptOpenCount === 1 ? transcriptPath : shrunkPath,
2159 options,
2160 );
2161 },
2162 });
2163
2164 const outcomes = yield* runRecentThreadOutcomes({
2165 claudeHomePath,
2166 codexHomePath,
2167 workspaceRoot: workspace,
2168 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
2169
2170 expect(transcriptOpenCount).toBe(2);
2171 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
2172 }),
2173 );
2174
2175 it.effect("does not read the second transcript when the consumer takes one thread", () =>
2176 Effect.gen(function* () {
2177 const path = yield* Path.Path;
2178 const fileSystem = yield* FileSystem.FileSystem;
2179 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2180 yield* TestClock.setTime(nowMs);
2181 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2182 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2183 const workspace = yield* makeTempDir("t3code-workspace-");
2184 const makeCodexTranscript = (sessionId: string, text: string) =>
2185 [
2186 encodeTranscriptRecord({
2187 type: "session_meta",
2188 payload: { id: sessionId, cwd: workspace },
2189 }),
2190 encodeTranscriptRecord({
2191 type: "event_msg",
2192 payload: { type: "user_message", message: text },
2193 }),
2194 ].join("\n");
2195 const olderPath = path.join(
2196 codexHomePath,
2197 "sessions",
2198 "2026",
2199 "08",
2200 "23",
2201 "rollout-older.jsonl",
2202 );
2203 const newerPath = path.join(
2204 codexHomePath,
2205 "sessions",
2206 "2026",
2207 "08",
2208 "24",
2209 "rollout-newer.jsonl",
2210 );
2211 yield* writeTranscript({
2212 filePath: olderPath,
2213 contents: makeCodexTranscript("older-session", "Older prompt"),
2214 mtimeMs: nowMs - 1_000,
2215 });
2216 yield* writeTranscript({
2217 filePath: newerPath,
2218 contents: makeCodexTranscript("newer-session", "Newer prompt"),
2219 mtimeMs: nowMs,
2220 });
2221
2222 const openCounts = new Map<string, number>();
2223 const contentReads: Array<string> = [];
2224 const trackedPaths = new Set([olderPath, newerPath]);
2225 const simulatedFileSystem = FileSystem.FileSystem.of({
2226 ...fileSystem,
2227 open: (filePath, options) => {
2228 if (trackedPaths.has(filePath)) {
2229 const count = (openCounts.get(filePath) ?? 0) + 1;
2230 openCounts.set(filePath, count);
2231 if (count === 2) contentReads.push(filePath);
2232 }
2233 return fileSystem.open(filePath, options);
2234 },
2235 });
2236
2237 const threads = yield* Effect.gen(function* () {
2238 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
2239 return yield* scanner.recentThreads(workspace).pipe(
2240 Stream.take(1),
2241 Stream.runCollect,
2242 Effect.map((items) => Array.from(items)),
2243 );
2244 }).pipe(
2245 Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })),
2246 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
2247 );
2248
2249 expect(
2250 threads.flatMap((outcome) =>
2251 outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [],
2252 ),
2253 ).toEqual(["newer-session"]);
2254 expect(contentReads).toEqual([newerPath]);
2255 expect(openCounts.get(olderPath)).toBe(1);
2256 }),
2257 );
2258
2259 it.effect("does not import sessions from a T3-managed worktree", () =>
2260 Effect.gen(function* () {
2261 const path = yield* Path.Path;
2262 const fileSystem = yield* FileSystem.FileSystem;
2263 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2264 yield* TestClock.setTime(nowMs);
2265 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2266 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2267 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
2268 const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree");
2269 yield* fileSystem.makeDirectory(workspace, { recursive: true });
2270
2271 yield* writeTranscript({
2272 filePath: path.join(
2273 codexHomePath,
2274 "sessions",
2275 "2026",
2276 "08",
2277 "24",
2278 "rollout-managed.jsonl",
2279 ),
2280 contents: [
2281 encodeTranscriptRecord({
2282 type: "session_meta",
2283 payload: { id: "managed-session", cwd: workspace },
2284 }),
2285 encodeTranscriptRecord({
2286 type: "event_msg",
2287 payload: { type: "user_message", message: "Do not import this session" },
2288 }),
2289 ].join("\n"),
2290 mtimeMs: nowMs,
2291 });
2292
2293 const threads = yield* runRecentThreads({
2294 claudeHomePath,
2295 codexHomePath,
2296 configBaseDir,
2297 workspaceRoot: workspace,
2298 });
2299
2300 expect(threads).toEqual([]);
2301 }),
2302 );
2303
2304 it.effect("uses one deterministic provider instance for a shared session home", () =>
2305 Effect.gen(function* () {
2306 const path = yield* Path.Path;
2307 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2308 yield* TestClock.setTime(nowMs);
2309 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2310 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2311 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
2312 const workspace = yield* makeTempDir("t3code-workspace-");
2313
2314 yield* writeTranscript({
2315 filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"),
2316 contents: [
2317 encodeTranscriptRecord({
2318 type: "session_meta",
2319 payload: { id: "shared-session", cwd: workspace },
2320 }),
2321 encodeTranscriptRecord({
2322 type: "event_msg",
2323 payload: { type: "user_message", message: "Use the shared session" },
2324 }),
2325 ].join("\n"),
2326 mtimeMs: nowMs,
2327 });
2328
2329 const threads = yield* runRecentThreads({
2330 claudeHomePath,
2331 codexHomePath,
2332 workspaceRoot: workspace,
2333 providerInstances: {
2334 [ProviderInstanceId.make("codex")]: {
2335 driver: ProviderDriverKind.make("codex"),
2336 config: { homePath: sharedHome },
2337 },
2338 [ProviderInstanceId.make("codex-personal")]: {
2339 driver: ProviderDriverKind.make("codex"),
2340 config: { homePath: sharedHome },
2341 },
2342 [ProviderInstanceId.make("codex-work")]: {
2343 driver: ProviderDriverKind.make("codex"),
2344 config: { homePath: sharedHome },
2345 },
2346 },
2347 });
2348
2349 expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]);
2350 }),
2351 );
2352
2353 it.effect("uses configured order when custom instances share a session home", () =>
2354 Effect.gen(function* () {
2355 const path = yield* Path.Path;
2356 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2357 yield* TestClock.setTime(nowMs);
2358 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2359 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2360 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
2361 const workspace = yield* makeTempDir("t3code-workspace-");
2362
2363 yield* writeTranscript({
2364 filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"),
2365 contents: [
2366 encodeTranscriptRecord({
2367 type: "session_meta",
2368 payload: { id: "shared-session", cwd: workspace },
2369 }),
2370 encodeTranscriptRecord({
2371 type: "event_msg",
2372 payload: { type: "user_message", message: "Use the first account" },
2373 }),
2374 ].join("\n"),
2375 mtimeMs: nowMs,
2376 });
2377
2378 const threads = yield* runRecentThreads({
2379 claudeHomePath,
2380 codexHomePath,
2381 workspaceRoot: workspace,
2382 providerInstances: {
2383 [ProviderInstanceId.make("codex-work")]: {
2384 driver: ProviderDriverKind.make("codex"),
2385 config: { homePath: sharedHome },
2386 },
2387 [ProviderInstanceId.make("codex-personal")]: {
2388 driver: ProviderDriverKind.make("codex"),
2389 config: { homePath: sharedHome },
2390 },
2391 },
2392 });
2393
2394 expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]);
2395 }),
2396 );
2397
2398 it.effect("keeps a second account when the first has 5000 newer files", () =>
2399 Effect.gen(function* () {
2400 const path = yield* Path.Path;
2401 const fileSystem = yield* FileSystem.FileSystem;
2402 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2403 yield* TestClock.setTime(nowMs);
2404 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2405 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2406 const oldWorkspace = yield* makeTempDir("t3code-workspace-old-");
2407 const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-");
2408 const recentHome = yield* makeTempDir("t3code-claude-recent-home-");
2409 const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old");
2410 const oldTranscript = path.join(oldDirectory, "old.jsonl");
2411 const recentDirectory = path.join(recentHome, "projects", "-zzz-recent");
2412
2413 yield* writeTranscript({
2414 filePath: oldTranscript,
2415 contents: encodeTranscriptRecord({
2416 type: "user",
2417 cwd: oldWorkspace,
2418 sessionId: "old-session",
2419 message: { role: "user", content: "Old work" },
2420 }),
2421 mtimeMs: nowMs,
2422 });
2423 yield* writeTranscript({
2424 filePath: path.join(recentDirectory, "recent.jsonl"),
2425 contents: encodeTranscriptRecord({
2426 type: "user",
2427 cwd: recentWorkspace,
2428 sessionId: "recent-session",
2429 message: { role: "user", content: "Recent work" },
2430 }),
2431 mtimeMs: nowMs - 1_000,
2432 });
2433
2434 const simulatedOldTranscripts = Array.from(
2435 { length: 5_000 },
2436 (_, index) => `old-${index}.jsonl`,
2437 );
2438 const resolveTranscript = (filePath: string) =>
2439 path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-")
2440 ? oldTranscript
2441 : filePath;
2442 const simulatedFileSystem = FileSystem.FileSystem.of({
2443 ...fileSystem,
2444 readDirectory: (directory, options) =>
2445 directory === oldDirectory
2446 ? Effect.succeed(simulatedOldTranscripts)
2447 : fileSystem.readDirectory(directory, options),
2448 stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)),
2449 open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options),
2450 });
2451
2452 const input = {
2453 claudeHomePath,
2454 codexHomePath,
2455 providerInstances: {
2456 [ProviderInstanceId.make("claude-work")]: {
2457 driver: ProviderDriverKind.make("claudeAgent"),
2458 config: { homePath: recentHome },
2459 },
2460 },
2461 };
2462 const threads = yield* Effect.gen(function* () {
2463 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
2464 const scan = yield* scanner.scan;
2465 expect(scan.truncated).toBe(true);
2466 return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect);
2467 }).pipe(
2468 Effect.provide(makeScannerTestLayer(input)),
2469 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
2470 );
2471
2472 expect(
2473 threads.flatMap((outcome) =>
2474 outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [],
2475 ),
2476 ).toEqual(["recent-session"]);
2477 }),
2478 );
2479 });
2480});
2481
2482describe("parseAgentSessionTranscript", () => {
2483 it.each([false, true])(
2484 "handles the exact record limit and an interior blank overflow=%s",
2485 (overflow) => {
2486 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2487 contents: makeRecordLimitTranscript("/project", overflow),
2488 source: "codex",
2489 providerInstanceId: ProviderInstanceId.make("codex"),
2490 fallbackSessionId: "unused",
2491 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2492 });
2493 if (overflow) expect(thread).toBeNull();
2494 else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]);
2495 },
2496 );
2497
2498 it("keeps Claude text and titles while dropping malformed and tool records", () => {
2499 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2500 contents: [
2501 "not valid json",
2502 JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }),
2503 JSON.stringify({
2504 type: "user",
2505 sessionId: "claude-session",
2506 isMeta: true,
2507 message: { role: "user", content: "Injected skill instructions" },
2508 }),
2509 JSON.stringify({
2510 type: "user",
2511 sessionId: "claude-session",
2512 isCompactSummary: true,
2513 message: { role: "user", content: "Injected compaction summary" },
2514 }),
2515 JSON.stringify({
2516 type: "user",
2517 sessionId: "claude-session",
2518 timestamp: "2026-08-24T10:00:00.000Z",
2519 message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] },
2520 }),
2521 JSON.stringify({
2522 type: "user",
2523 sessionId: "claude-session",
2524 message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] },
2525 }),
2526 JSON.stringify({
2527 type: "assistant",
2528 sessionId: "claude-session",
2529 message: {
2530 role: "assistant",
2531 model: "claude-sonnet-5",
2532 content: [{ type: "text", text: "Updated the login flow" }],
2533 },
2534 }),
2535 JSON.stringify({
2536 type: "assistant",
2537 sessionId: "claude-session",
2538 message: {
2539 role: "assistant",
2540 model: "<synthetic>",
2541 content: [{ type: "text", text: "The provider request failed" }],
2542 },
2543 }),
2544 ].join("\n"),
2545 source: "claudeAgent",
2546 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
2547 fallbackSessionId: "fallback",
2548 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2549 });
2550
2551 expect(thread).toMatchObject({
2552 providerSessionId: "claude-session",
2553 title: "Fix authentication",
2554 model: "claude-sonnet-5",
2555 messages: [
2556 { role: "user", text: "Fix authentication" },
2557 { role: "assistant", text: "Updated the login flow" },
2558 { role: "assistant", text: "The provider request failed" },
2559 ],
2560 });
2561 });
2562
2563 it("drops injected Codex instructions while keeping the visible user event", () => {
2564 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2565 contents: [
2566 JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }),
2567 JSON.stringify({
2568 type: "response_item",
2569 payload: {
2570 type: "message",
2571 role: "user",
2572 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2573 content: [
2574 {
2575 type: "input_text",
2576 text: "<user_instructions>\nInternal setup instructions\n</user_instructions>",
2577 },
2578 ],
2579 },
2580 }),
2581 JSON.stringify({
2582 type: "event_msg",
2583 payload: { type: "user_message", message: "Fix the actual bug" },
2584 }),
2585 JSON.stringify({
2586 type: "response_item",
2587 payload: {
2588 type: "message",
2589 role: "user",
2590 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2591 content: [{ type: "input_text", text: "Fix the actual bug" }],
2592 },
2593 }),
2594 JSON.stringify({
2595 type: "response_item",
2596 payload: {
2597 type: "message",
2598 role: "assistant",
2599 content: [{ type: "output_text", text: "Fixed" }],
2600 },
2601 }),
2602 ].join("\n"),
2603 source: "codex",
2604 providerInstanceId: ProviderInstanceId.make("codex"),
2605 fallbackSessionId: "fallback",
2606 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2607 });
2608
2609 expect(thread?.messages.map((message) => message.text)).toEqual([
2610 "Fix the actual bug",
2611 "Fixed",
2612 ]);
2613 });
2614
2615 it("keeps the canonical first prompt after long Codex transcripts are capped", () => {
2616 const canonicalPrompt = "\n Keep the canonical prompt \n";
2617 const canonicalTimestamp = "2026-08-24T10:01:00.000Z";
2618 const laterAssistantMessages = Array.from({ length: 200 }, (_, index) =>
2619 encodeTranscriptRecord({
2620 type: "response_item",
2621 timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
2622 payload: {
2623 type: "message",
2624 role: "assistant",
2625 content: [{ type: "output_text", text: `Assistant message ${index}` }],
2626 },
2627 }),
2628 );
2629 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2630 contents: [
2631 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2632 encodeTranscriptRecord({
2633 type: "response_item",
2634 timestamp: "2026-08-24T10:00:00.000Z",
2635 payload: {
2636 type: "message",
2637 role: "user",
2638 content: [{ type: "input_text", text: "Keep the canonical prompt" }],
2639 },
2640 }),
2641 encodeTranscriptRecord({
2642 type: "event_msg",
2643 timestamp: canonicalTimestamp,
2644 payload: { type: "user_message", message: canonicalPrompt },
2645 }),
2646 ...laterAssistantMessages,
2647 ].join("\n"),
2648 source: "codex",
2649 providerInstanceId: ProviderInstanceId.make("codex"),
2650 fallbackSessionId: "fallback",
2651 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2652 });
2653
2654 expect(thread?.messages).toHaveLength(200);
2655 expect(thread?.messages[0]).toMatchObject({
2656 role: "user",
2657 text: canonicalPrompt,
2658 createdAt: canonicalTimestamp,
2659 });
2660 });
2661
2662 it("restores the canonical first prompt when a later user message remains", () => {
2663 const canonicalPrompt = "\n Keep the canonical prompt \n";
2664 const canonicalTimestamp = "2026-08-24T10:01:00.000Z";
2665 const assistantMessages = Array.from({ length: 198 }, (_, index) =>
2666 encodeTranscriptRecord({
2667 type: "response_item",
2668 timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
2669 payload: {
2670 type: "message",
2671 role: "assistant",
2672 content: [{ type: "output_text", text: `Assistant message ${index}` }],
2673 },
2674 }),
2675 );
2676 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2677 contents: [
2678 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2679 encodeTranscriptRecord({
2680 type: "response_item",
2681 timestamp: "2026-08-24T10:00:00.000Z",
2682 payload: {
2683 type: "message",
2684 role: "user",
2685 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2686 content: [{ type: "input_text", text: "Keep the canonical prompt" }],
2687 },
2688 }),
2689 encodeTranscriptRecord({
2690 type: "event_msg",
2691 timestamp: canonicalTimestamp,
2692 payload: { type: "user_message", message: canonicalPrompt },
2693 }),
2694 ...assistantMessages,
2695 encodeTranscriptRecord({
2696 type: "event_msg",
2697 timestamp: "2026-08-24T11:58:30.000Z",
2698 payload: { type: "user_message", message: "Keep this later prompt" },
2699 }),
2700 encodeTranscriptRecord({
2701 type: "response_item",
2702 timestamp: "2026-08-24T11:59:00.000Z",
2703 payload: {
2704 type: "message",
2705 role: "assistant",
2706 content: [{ type: "output_text", text: "Keep this latest response" }],
2707 },
2708 }),
2709 ].join("\n"),
2710 source: "codex",
2711 providerInstanceId: ProviderInstanceId.make("codex"),
2712 fallbackSessionId: "fallback",
2713 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2714 });
2715
2716 expect(thread?.messages).toHaveLength(200);
2717 expect(thread?.messages[0]).toMatchObject({
2718 role: "user",
2719 text: canonicalPrompt,
2720 createdAt: canonicalTimestamp,
2721 });
2722 expect(
2723 thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()),
2724 ).toHaveLength(1);
2725 expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe(
2726 true,
2727 );
2728 expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response");
2729 });
2730
2731 it("keeps mixed-format response users when turn IDs repeat after an assistant", () => {
2732 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2733 contents: [
2734 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2735 encodeTranscriptRecord({
2736 type: "response_item",
2737 payload: {
2738 type: "message",
2739 role: "user",
2740 internal_chat_message_metadata_passthrough: { turn_id: "turn-older" },
2741 content: [{ type: "input_text", text: "Keep this older prompt" }],
2742 },
2743 }),
2744 encodeTranscriptRecord({
2745 type: "event_msg",
2746 payload: { type: "user_message", message: "Keep this newer prompt" },
2747 }),
2748 encodeTranscriptRecord({
2749 type: "response_item",
2750 payload: {
2751 type: "message",
2752 role: "user",
2753 internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" },
2754 content: [{ type: "input_text", text: "Keep this newer prompt" }],
2755 },
2756 }),
2757 encodeTranscriptRecord({
2758 type: "response_item",
2759 payload: {
2760 type: "message",
2761 role: "assistant",
2762 content: [{ type: "output_text", text: "Ask again when needed" }],
2763 },
2764 }),
2765 encodeTranscriptRecord({
2766 type: "response_item",
2767 payload: {
2768 type: "message",
2769 role: "user",
2770 internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" },
2771 content: [{ type: "input_text", text: "Keep this newer prompt" }],
2772 },
2773 }),
2774 ].join("\n"),
2775 source: "codex",
2776 providerInstanceId: ProviderInstanceId.make("codex"),
2777 fallbackSessionId: "fallback",
2778 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2779 });
2780
2781 expect(thread?.messages.map((message) => message.text)).toEqual([
2782 "Keep this older prompt",
2783 "Keep this newer prompt",
2784 "Ask again when needed",
2785 "Keep this newer prompt",
2786 ]);
2787 });
2788
2789 it("preserves response user text when Codex turn metadata is ambiguous", () => {
2790 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2791 contents: [
2792 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2793 encodeTranscriptRecord({
2794 type: "response_item",
2795 payload: {
2796 type: "message",
2797 role: "user",
2798 internal_chat_message_metadata_passthrough: ["unexpected"],
2799 content: [{ type: "input_text", text: "Keep this legacy prompt" }],
2800 },
2801 }),
2802 encodeTranscriptRecord({
2803 type: "response_item",
2804 payload: {
2805 type: "message",
2806 role: "user",
2807 internal_chat_message_metadata_passthrough: { turn_id: " " },
2808 content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }],
2809 },
2810 }),
2811 ].join("\n"),
2812 source: "codex",
2813 providerInstanceId: ProviderInstanceId.make("codex"),
2814 fallbackSessionId: "fallback",
2815 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2816 });
2817
2818 expect(thread?.messages.map((message) => message.text)).toEqual([
2819 "Keep this legacy prompt",
2820 "Keep this prompt with a blank turn ID",
2821 ]);
2822 });
2823
2824 it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => {
2825 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2826 contents: [
2827 encodeTranscriptRecord({
2828 type: "session_meta",
2829 payload: { id: "fork-session", forked_from_id: "parent-session" },
2830 }),
2831 encodeTranscriptRecord({
2832 type: "session_meta",
2833 payload: { id: "parent-session" },
2834 }),
2835 encodeTranscriptRecord({
2836 type: "event_msg",
2837 payload: { type: "user_message", message: "Continue in the fork" },
2838 }),
2839 ].join("\n"),
2840 source: "codex",
2841 providerInstanceId: ProviderInstanceId.make("codex"),
2842 fallbackSessionId: "fallback",
2843 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2844 });
2845
2846 expect(thread?.providerSessionId).toBe("fork-session");
2847 });
2848
2849 it("skips Codex transcripts without a resumable session ID", () => {
2850 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2851 contents: encodeTranscriptRecord({
2852 type: "event_msg",
2853 payload: { type: "user_message", message: "This transcript has no session metadata" },
2854 }),
2855 source: "codex",
2856 providerInstanceId: ProviderInstanceId.make("codex"),
2857 fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id",
2858 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2859 });
2860
2861 expect(thread).toBeNull();
2862 });
2863
2864 it("uses the canonical Codex event when its turn has generated response context", () => {
2865 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2866 contents: [
2867 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2868 encodeTranscriptRecord({
2869 type: "response_item",
2870 payload: {
2871 type: "message",
2872 role: "user",
2873 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2874 content: [
2875 {
2876 type: "input_text",
2877 text: "<environment_context>\n<cwd>/tmp/project</cwd>\n<shell>zsh</shell>\n</environment_context>",
2878 },
2879 ],
2880 },
2881 }),
2882 encodeTranscriptRecord({
2883 type: "response_item",
2884 payload: {
2885 type: "message",
2886 role: "user",
2887 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2888 content: [
2889 {
2890 type: "input_text",
2891 text: "# AGENTS.md instructions for /tmp/project\n\n<INSTRUCTIONS>\nPrivate project rules\n</INSTRUCTIONS>",
2892 },
2893 ],
2894 },
2895 }),
2896 encodeTranscriptRecord({
2897 type: "event_msg",
2898 payload: {
2899 type: "user_message",
2900 message: "Do something here so it looks like a real project.",
2901 },
2902 }),
2903 encodeTranscriptRecord({
2904 type: "response_item",
2905 payload: {
2906 type: "message",
2907 role: "user",
2908 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2909 content: [
2910 {
2911 type: "input_text",
2912 text: "Do something here so it looks like a real project.",
2913 },
2914 ],
2915 },
2916 }),
2917 encodeTranscriptRecord({
2918 type: "response_item",
2919 payload: {
2920 type: "message",
2921 role: "assistant",
2922 content: [{ type: "output_text", text: "Created the project." }],
2923 },
2924 }),
2925 ].join("\n"),
2926 source: "codex",
2927 providerInstanceId: ProviderInstanceId.make("codex"),
2928 fallbackSessionId: "fallback",
2929 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
2930 });
2931
2932 expect(thread?.title).toBe("Do something here so it looks like a real project.");
2933 expect(thread?.messages.map((message) => message.text)).toEqual([
2934 "Do something here so it looks like a real project.",
2935 "Created the project.",
2936 ]);
2937 });
2938
2939 it("preserves context markup in response-only Codex messages", () => {
2940 const context = "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>";
2941 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2942 contents: [
2943 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2944 encodeTranscriptRecord({
2945 type: "response_item",
2946 payload: {
2947 type: "message",
2948 role: "user",
2949 content: [
2950 {
2951 type: "input_text",
2952 text: context,
2953 },
2954 ],
2955 },
2956 }),
2957 encodeTranscriptRecord({
2958 type: "response_item",
2959 payload: {
2960 type: "message",
2961 role: "user",
2962 content: [{ type: "input_text", text: "Initialize Git and add a README." }],
2963 },
2964 }),
2965 ].join("\n"),
2966 source: "codex",
2967 providerInstanceId: ProviderInstanceId.make("codex"),
2968 fallbackSessionId: "fallback",
2969 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
2970 });
2971
2972 expect(thread?.title).toBe("<environment_context>");
2973 expect(thread?.messages.map((message) => message.text)).toEqual([
2974 context,
2975 "Initialize Git and add a README.",
2976 ]);
2977 });
2978
2979 it("preserves a canonical Codex event that starts with context markup", () => {
2980 const prompt =
2981 "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>\n\nCreate a useful project.";
2982 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2983 contents: [
2984 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2985 encodeTranscriptRecord({
2986 type: "event_msg",
2987 payload: {
2988 type: "user_message",
2989 message: prompt,
2990 },
2991 }),
2992 ].join("\n"),
2993 source: "codex",
2994 providerInstanceId: ProviderInstanceId.make("codex"),
2995 fallbackSessionId: "fallback",
2996 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
2997 });
2998
2999 expect(thread?.title).toBe("<environment_context>");
3000 expect(thread?.messages.map((message) => message.text)).toEqual([prompt]);
3001 });
3002
3003 it("preserves a Codex request heading in a canonical event", () => {
3004 const prompt = "\n ## My request for Codex:\n\nFix the visible bug";
3005 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3006 contents: [
3007 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
3008 encodeTranscriptRecord({
3009 type: "event_msg",
3010 payload: {
3011 type: "user_message",
3012 message: prompt,
3013 },
3014 }),
3015 ].join("\n"),
3016 source: "codex",
3017 providerInstanceId: ProviderInstanceId.make("codex"),
3018 fallbackSessionId: "fallback",
3019 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3020 });
3021
3022 expect(thread?.title).toBe("## My request for Codex:");
3023 expect(thread?.messages.map((message) => message.text)).toEqual([prompt]);
3024 });
3025
3026 it("keeps context markup quoted inside visible Codex user text", () => {
3027 const quoted =
3028 "Do not remove this example:\n<environment_context>\n<cwd>/tmp/example</cwd>\n</environment_context>";
3029 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3030 contents: [
3031 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
3032 encodeTranscriptRecord({
3033 type: "event_msg",
3034 payload: { type: "user_message", message: quoted },
3035 }),
3036 ].join("\n"),
3037 source: "codex",
3038 providerInstanceId: ProviderInstanceId.make("codex"),
3039 fallbackSessionId: "fallback",
3040 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3041 });
3042
3043 expect(thread?.messages.map((message) => message.text)).toEqual([quoted]);
3044 });
3045
3046 it("skips sessions without a visible user message", () => {
3047 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3048 contents: JSON.stringify({
3049 type: "assistant",
3050 message: { role: "assistant", content: "Done" },
3051 }),
3052 source: "claudeAgent",
3053 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
3054 fallbackSessionId: "claude-session",
3055 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
3056 });
3057
3058 expect(thread).toBeNull();
3059 });
3060
3061 it("keeps the first prompt when later assistant output exceeds the message limit", () => {
3062 const transcript = [
3063 encodeTranscriptRecord({
3064 type: "user",
3065 sessionId: "claude-session",
3066 message: { role: "user", content: "Keep this prompt" },
3067 }),
3068 ...Array.from({ length: 250 }, (_, index) =>
3069 encodeTranscriptRecord({
3070 type: "assistant",
3071 message: { role: "assistant", content: `Assistant update ${index}` },
3072 }),
3073 ),
3074 ].join("\n");
3075
3076 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3077 contents: transcript,
3078 source: "claudeAgent",
3079 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
3080 fallbackSessionId: "fallback",
3081 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
3082 });
3083
3084 expect(thread?.messages).toHaveLength(200);
3085 expect(thread?.messages[0]?.text).toBe("Keep this prompt");
3086 expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249");
3087 });
3088});
apps/server/src/project/AgentSessionScanner.ts created+1313
...@@ -0,0 +1,1313 @@
1/**
2 * AgentSessionScanner - discovery of projects a user already works on.
3 *
4 * Claude Code and Codex both keep a per-session transcript on disk, and each
5 * transcript records the directory the session ran in. Reading those `cwd`
6 * values gives us the set of directories worth offering as projects during
7 * onboarding, without asking the user to browse the filesystem.
8 *
9 * The scan is read-only and best-effort: an unreadable home, a malformed
10 * transcript, or a directory that has since been deleted is skipped rather
11 * than failing the scan. Project creation stays with the client, which
12 * dispatches `project.create` for whichever candidates the user picks.
13 *
14 * @module project/AgentSessionScanner
15 */
16import * as NodeOS from "node:os";
17
18import {
19 AgentSessionScanError,
20 ClaudeSettings,
21 CodexSettings,
22 ProviderDriverKind,
23 ProviderInstanceId,
24 resolveProviderInstanceEnabled,
25 type AgentSessionImportSource,
26 type AgentSessionProjectCandidate,
27 type AgentSessionScanResult,
28 type ProviderInstanceConfig,
29} from "@t3tools/contracts";
30import * as Context from "effect/Context";
31import * as DateTime from "effect/DateTime";
32import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
34import * as Layer from "effect/Layer";
35import * as Option from "effect/Option";
36import * as Path from "effect/Path";
37import * as Schema from "effect/Schema";
38import * as Stream from "effect/Stream";
39
40import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
41import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
42
43import * as ServerConfig from "../config.ts";
44import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
45import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
46import { expandHomePath } from "../pathExpansion.ts";
47import * as ServerSettings from "../serverSettings.ts";
48
49/** Chunk size for full transcript reads. */
50const TRANSCRIPT_PREFIX_BYTES = 32 * 1024;
51/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */
52const METADATA_READ_BYTES = 8 * 1024;
53/** Prevent malformed transcripts from turning project discovery into a full file scan. */
54const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024;
55
56/**
57 * Upper bound on transcripts inspected (first line read) per source.
58 * Newest-first ordering means the cap drops only stale sessions when a home
59 * directory is unusually large.
60 */
61const MAX_TRANSCRIPTS_PER_SOURCE = 5000;
62
63/**
64 * Upper bound on discovery filesystem operations per source. Newest-first
65 * ordering needs mtimes before the read cap can be applied, so directory reads
66 * and candidate stats share a larger budget. Once it runs out the scan stops.
67 */
68const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4;
69const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024;
70const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4;
71const MAX_METADATA_RECORDS_PER_SOURCE = 100_000;
72const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000;
73const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
74const MAX_IMPORTED_TRANSCRIPT_BYTES = 16 * 1024 * 1024;
75const MAX_IMPORTED_MESSAGES = 200;
76const MAX_IMPORT_BYTES = 64 * 1024 * 1024;
77const MAX_IMPORT_TRANSCRIPTS = 100;
78const MAX_IMPORT_RECORDS = 100_000;
79
80const TranscriptContentBlock = Schema.Struct({
81 type: Schema.optional(Schema.String),
82 text: Schema.optional(Schema.String),
83});
84
85const TranscriptMessage = Schema.Struct({
86 role: Schema.optional(Schema.String),
87 content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])),
88 model: Schema.optional(Schema.String),
89});
90
91const CodexTurnMetadata = Schema.Struct({
92 turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])),
93});
94
95const TranscriptRecord = Schema.Struct({
96 type: Schema.optional(Schema.String),
97 timestamp: Schema.optional(Schema.String),
98 sessionId: Schema.optional(Schema.String),
99 aiTitle: Schema.optional(Schema.String),
100 isSidechain: Schema.optional(Schema.Boolean),
101 isMeta: Schema.optional(Schema.Boolean),
102 isCompactSummary: Schema.optional(Schema.Boolean),
103 message: Schema.optional(TranscriptMessage),
104 payload: Schema.optional(
105 Schema.Struct({
106 id: Schema.optional(Schema.String),
107 session_id: Schema.optional(Schema.String),
108 type: Schema.optional(Schema.String),
109 role: Schema.optional(Schema.String),
110 message: Schema.optional(Schema.String),
111 model: Schema.optional(Schema.String),
112 content: Schema.optional(Schema.Array(TranscriptContentBlock)),
113 internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown),
114 }),
115 ),
116});
117
118const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
119const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
120const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord));
121const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata);
122
123export interface AgentSessionThreadMessage {
124 readonly role: "user" | "assistant";
125 readonly text: string;
126 readonly createdAt: string;
127}
128
129export interface AgentSessionThread {
130 readonly source: AgentSessionSource;
131 readonly providerInstanceId: ProviderInstanceId;
132 readonly providerSessionId: string;
133 readonly title: string;
134 readonly model: string | null;
135 readonly createdAt: string;
136 readonly updatedAt: string;
137 readonly messages: ReadonlyArray<AgentSessionThreadMessage>;
138}
139
140export type AgentSessionRecentThread =
141 | {
142 readonly _tag: "Importable";
143 readonly thread: AgentSessionThread;
144 readonly source: AgentSessionImportSource;
145 }
146 | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource }
147 | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource }
148 | { readonly _tag: "Skipped" };
149
150/** Service tag for agent session discovery. */
151export class AgentSessionScanner extends Context.Service<
152 AgentSessionScanner,
153 {
154 /**
155 * Discover every directory the configured Claude and Codex homes have run
156 * a session in. Candidates are returned newest-first; the client decides
157 * which ones to import and how far back to look. Fails with the contract
158 * error directly — there is no server-local context worth wrapping.
159 */
160 readonly scan: Effect.Effect<AgentSessionScanResult, AgentSessionScanError>;
161 readonly recentThreads: (
162 workspaceRoot: string,
163 completedSources?: ReadonlyArray<AgentSessionImportSource>,
164 ) => Stream.Stream<AgentSessionRecentThread, AgentSessionScanError>;
165 }
166>()("t3/project/AgentSessionScanner") {}
167
168type AgentSessionSource = AgentSessionProjectCandidate["sources"][number];
169
170/** A single directory's worth of evidence from one source. */
171interface RawCandidate {
172 readonly cwd: string;
173 readonly source: AgentSessionSource;
174 readonly providerInstanceId: ProviderInstanceId;
175 readonly threadCount: number;
176 readonly lastActiveAtMs: number | null;
177 readonly transcripts: ReadonlyArray<{
178 readonly filePath: string;
179 readonly mtimeMs: number | null;
180 }>;
181}
182
183interface TranscriptCandidate {
184 readonly filePath: string;
185 readonly mtimeMs: number;
186 readonly providerInstanceId: ProviderInstanceId;
187 readonly size: number;
188}
189
190interface MetadataReadBudget {
191 bytesRemaining: number;
192 operationsRemaining: number;
193 recordsRemaining: number;
194 truncated: boolean;
195}
196
197function selectMetadataTranscripts(transcripts: ReadonlyArray<TranscriptCandidate>) {
198 const selected: Array<TranscriptCandidate> = [];
199 let pending = Array.from(
200 Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(),
201 (entries) => entries.values(),
202 );
203 while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) {
204 const nextRound: typeof pending = [];
205 for (const iterator of pending) {
206 if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break;
207 const next = iterator.next();
208 if (next.done) continue;
209 selected.push(next.value);
210 nextRound.push(iterator);
211 }
212 pending = nextRound;
213 }
214 return selected;
215}
216
217function splitTranscriptRecords(contents: string, limit: number): string[] {
218 const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents;
219 return records.split("\n", limit);
220}
221
222function extractText(
223 content: string | ReadonlyArray<typeof TranscriptContentBlock.Type> | undefined,
224): string {
225 if (typeof content === "string") return content.trim();
226 if (content === undefined) return "";
227 return content
228 .filter(
229 (block) =>
230 block.type === "text" || block.type === "input_text" || block.type === "output_text",
231 )
232 .map((block) => block.text?.trim() ?? "")
233 .filter((text) => text.length > 0)
234 .join("\n");
235}
236
237function normalizeTimestamp(value: string | undefined, fallback: string): string {
238 if (value === undefined) return fallback;
239 const parsed = DateTime.make(value);
240 return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback;
241}
242
243function codexTurnId(metadata: unknown): string | null {
244 const decoded = decodeCodexTurnMetadata(metadata);
245 if (
246 Option.isNone(decoded) ||
247 typeof decoded.value.turn_id !== "string" ||
248 decoded.value.turn_id.trim().length === 0
249 ) {
250 return null;
251 }
252 return decoded.value.turn_id;
253}
254
255/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */
256export function parseAgentSessionTranscript(
257 input: {
258 readonly contents: string;
259 readonly source: AgentSessionSource;
260 readonly providerInstanceId: ProviderInstanceId;
261 readonly fallbackSessionId: string;
262 readonly lastActiveAtMs: number;
263 },
264 lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1),
265): AgentSessionThread | null {
266 if (lines.length > MAX_IMPORT_RECORDS) return null;
267 const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs));
268 // Claude filenames are session IDs. Codex rollout filenames include extra
269 // timestamp text, so only transcript metadata can provide a resumable ID.
270 let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId;
271 let title: string | null = null;
272 let model: string | null = null;
273 let hasCodexSessionId = false;
274 const messages: Array<AgentSessionThreadMessage & { readonly codexResponseUser: boolean }> = [];
275 let firstUserMessage:
276 | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean })
277 | undefined;
278 function* decodedRecords() {
279 for (const line of lines) {
280 const decoded = decodeTranscriptRecord(line);
281 if (Option.isSome(decoded)) yield decoded.value;
282 }
283 }
284
285 // A Codex response item can include generated setup text beside the real
286 // prompt. Suppress response-user records only when the shared turn ID and a
287 // verbatim event copy prove which prompt the user submitted.
288 const canonicalCodexResponseUserIndices = new Set<number>();
289 let canonicalUserTextsInTurn = new Set<string>();
290 let responseUsersInTurn: Array<{
291 readonly index: number;
292 readonly turnId: string;
293 readonly text: string;
294 }> = [];
295 const finishCodexTurn = () => {
296 const canonicalTurnIds = new Set(
297 responseUsersInTurn.flatMap((responseUser) =>
298 canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [],
299 ),
300 );
301 for (const responseUser of responseUsersInTurn) {
302 if (canonicalTurnIds.has(responseUser.turnId)) {
303 canonicalCodexResponseUserIndices.add(responseUser.index);
304 }
305 }
306 canonicalUserTextsInTurn = new Set();
307 responseUsersInTurn = [];
308 };
309 if (input.source === "codex") {
310 let recordIndex = -1;
311 for (const record of decodedRecords()) {
312 recordIndex += 1;
313 if (
314 record.type === "response_item" &&
315 record.payload?.type === "message" &&
316 record.payload.role === "assistant"
317 ) {
318 finishCodexTurn();
319 continue;
320 }
321 if (record.type === "event_msg" && record.payload?.type === "user_message") {
322 const text = record.payload.message?.trim() ?? "";
323 if (text.length > 0) canonicalUserTextsInTurn.add(text);
324 continue;
325 }
326 if (
327 record.type === "response_item" &&
328 record.payload?.type === "message" &&
329 record.payload.role === "user"
330 ) {
331 const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough);
332 const text = extractText(record.payload.content);
333 if (turnId !== null && text.length > 0) {
334 responseUsersInTurn.push({ index: recordIndex, turnId, text });
335 }
336 }
337 }
338 finishCodexTurn();
339 }
340
341 const retainMessage = (
342 message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean },
343 ) => {
344 if (firstUserMessage === undefined && message.role === "user") {
345 firstUserMessage = message;
346 }
347 messages.push(message);
348 if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift();
349 };
350
351 const hasMatchingCodexEventInTurn = (text: string) => {
352 const comparisonText = text.trim();
353 for (let index = messages.length - 1; index >= 0; index--) {
354 const message = messages[index];
355 if (message?.role === "assistant") return false;
356 if (
357 message?.role === "user" &&
358 !message.codexResponseUser &&
359 message.text.trim() === comparisonText
360 ) {
361 return true;
362 }
363 }
364 return false;
365 };
366
367 let recordIndex = -1;
368 for (const record of decodedRecords()) {
369 recordIndex += 1;
370 if (input.source === "claudeAgent") {
371 if (
372 record.isSidechain === true ||
373 record.isMeta === true ||
374 record.isCompactSummary === true
375 ) {
376 continue;
377 }
378 if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim();
379 if (record.aiTitle?.trim()) title = record.aiTitle.trim();
380 const messageModel = record.message?.model?.trim();
381 // Claude uses this sentinel for local error responses. It is not a
382 // model ID that can be selected when the imported session resumes.
383 if (messageModel && messageModel !== "<synthetic>") model = messageModel;
384 if (record.type !== "user" && record.type !== "assistant") {
385 continue;
386 }
387
388 const text = extractText(record.message?.content);
389 if (text.length === 0) continue;
390 retainMessage({
391 role: record.type,
392 text,
393 createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
394 codexResponseUser: false,
395 });
396 continue;
397 }
398
399 if (record.type === "session_meta") {
400 const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim();
401 if (!hasCodexSessionId && sessionId) {
402 providerSessionId = sessionId;
403 hasCodexSessionId = true;
404 }
405 continue;
406 }
407 if (record.type === "turn_context" && record.payload?.model?.trim()) {
408 model = record.payload.model.trim();
409 continue;
410 }
411 if (record.type === "event_msg" && record.payload?.type === "user_message") {
412 const text = record.payload.message ?? "";
413 if (text.trim().length === 0) continue;
414 // Codex can write the same prompt as both a response item and an event.
415 // Remove only the matching response copy so mixed-format logs keep every
416 // distinct user message.
417 for (let index = messages.length - 1; index >= 0; index--) {
418 const message = messages[index];
419 if (message?.role === "assistant") break;
420 if (message?.codexResponseUser === true && message.text.trim() === text.trim()) {
421 if (firstUserMessage === message) firstUserMessage = undefined;
422 messages.splice(index, 1);
423 break;
424 }
425 }
426 retainMessage({
427 role: "user",
428 text,
429 createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
430 codexResponseUser: false,
431 });
432 continue;
433 }
434 if (
435 record.type !== "response_item" ||
436 record.payload?.type !== "message" ||
437 (record.payload.role !== "user" && record.payload.role !== "assistant")
438 ) {
439 continue;
440 }
441
442 const extractedText = extractText(record.payload.content);
443 if (extractedText.length === 0) continue;
444 if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) {
445 continue;
446 }
447 if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) {
448 continue;
449 }
450 retainMessage({
451 role: record.payload.role,
452 text: extractedText,
453 createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
454 codexResponseUser: record.payload.role === "user",
455 });
456 }
457
458 const visibleMessages = messages.map(
459 ({ codexResponseUser: _codexResponseUser, ...message }) => message,
460 );
461 if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null;
462 const firstUserMessageRetained = messages.includes(firstUserMessage);
463 const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage;
464 const retainedMessages = firstUserMessageRetained
465 ? visibleMessages
466 : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))];
467 const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim();
468
469 return {
470 source: input.source,
471 providerInstanceId: input.providerInstanceId,
472 providerSessionId,
473 title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"),
474 model,
475 createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp,
476 updatedAt: fallbackTimestamp,
477 messages: retainedMessages,
478 };
479}
480
481/**
482 * T3 Code runs its own agent sessions inside disposable worktrees. Their
483 * transcripts look exactly like user sessions, but re-importing the app's own
484 * sandboxes as projects is never right. Matches this server's configured
485 * worktrees directory plus the conventional `.t3/worktrees` layout, which
486 * also catches sandboxes from other T3 homes on the same machine. Separators
487 * are normalized (and, on Windows, case folded) so the prefix match holds
488 * there too. Callers check both the recorded spelling and its realpath so a
489 * symlink into the worktrees directory cannot bypass the filter.
490 */
491function normalizeForWorktreeMatch(value: string, caseFold: boolean): string {
492 const normalized = `${value.replaceAll("\\", "/")}/`;
493 return caseFold ? normalized.toLowerCase() : normalized;
494}
495
496function isT3ManagedWorktree(
497 candidatePath: string,
498 worktreesDir: string,
499 caseFold: boolean,
500): boolean {
501 const normalized = normalizeForWorktreeMatch(candidatePath, caseFold);
502 return (
503 normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) ||
504 normalized.includes("/.t3/worktrees/")
505 );
506}
507
508/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */
509function extractCwd(line: string): string | null {
510 let parsed: unknown;
511 try {
512 parsed = JSON.parse(line);
513 } catch {
514 return null;
515 }
516 if (typeof parsed !== "object" || parsed === null) return null;
517
518 const record = parsed as Record<string, unknown>;
519 if (typeof record.cwd === "string" && record.cwd.trim().length > 0) {
520 return record.cwd;
521 }
522 // Codex nests session metadata under `payload`.
523 const payload = record.payload;
524 if (typeof payload === "object" && payload !== null) {
525 const nested = (payload as Record<string, unknown>).cwd;
526 if (typeof nested === "string" && nested.trim().length > 0) {
527 return nested;
528 }
529 }
530 return null;
531}
532
533function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) {
534 return {
535 filePath,
536 size: Number(stats.size),
537 mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }),
538 device: stats.dev,
539 inode: Option.getOrNull(stats.ino),
540 birthtimeMs: Option.match(stats.birthtime, {
541 onNone: () => null,
542 onSome: (date) => date.getTime(),
543 }),
544 };
545}
546
547function sameTranscriptIdentity(
548 left: ReturnType<typeof transcriptIdentity>,
549 right: ReturnType<typeof transcriptIdentity>,
550): boolean {
551 return (
552 left.filePath === right.filePath &&
553 left.size === right.size &&
554 left.mtimeMs === right.mtimeMs &&
555 left.device === right.device &&
556 left.inode === right.inode &&
557 left.birthtimeMs === right.birthtimeMs
558 );
559}
560
561export const make = Effect.gen(function* () {
562 const fileSystem = yield* FileSystem.FileSystem;
563 const path = yield* Path.Path;
564 const serverConfig = yield* ServerConfig.ServerConfig;
565 const serverSettings = yield* ServerSettings.ServerSettingsService;
566 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
567 const baseDir = path.resolve(serverConfig.baseDir);
568 const worktreesDir = path.resolve(serverConfig.worktreesDir);
569 // Windows filesystems are case-insensitive, so path prefix checks there
570 // must case fold.
571 const foldWorktreeCase = (yield* HostProcessPlatform) === "win32";
572 const hostEnvironment = yield* HostProcessEnvironment;
573 const excludedProjectRoots = new Set(
574 [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) =>
575 normalizeProjectPathForComparison(path.resolve(directory)),
576 ),
577 );
578
579 const isExcludedProjectPath = (candidatePath: string) =>
580 excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
581 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
582 normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
583 ) ||
584 isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase);
585
586 const listDirectory = (directory: string) =>
587 fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
588
589 const statOption = (target: string) =>
590 fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none));
591
592 /** Match directory aliases without assuming the host volume is case-insensitive. */
593 const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* (
594 target: string,
595 knownStats?: FileSystem.File.Info,
596 ) {
597 const resolved = path.resolve(target);
598 const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats);
599 if (
600 Option.isSome(stats) &&
601 Option.isSome(stats.value.ino) &&
602 Number.isSafeInteger(stats.value.ino.value) &&
603 stats.value.ino.value > 0
604 ) {
605 return `inode:${stats.value.dev}:${stats.value.ino.value}`;
606 }
607 const realPath = yield* fileSystem
608 .realPath(resolved)
609 .pipe(Effect.orElseSucceed(() => resolved));
610 return `path:${normalizeProjectPathForComparison(realPath)}`;
611 });
612
613 // A large history snapshot can precede session metadata. Read bounded
614 // chunks until a complete record names its cwd or the safety budget ends.
615 const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (
616 transcript: TranscriptCandidate,
617 budget: MetadataReadBudget,
618 ) {
619 if (transcript.size === 0) return null;
620 if (
621 budget.bytesRemaining === 0 ||
622 budget.operationsRemaining < 2 ||
623 budget.recordsRemaining === 0
624 ) {
625 budget.truncated = true;
626 return null;
627 }
628 budget.operationsRemaining -= 1;
629 return yield* Effect.scoped(
630 fileSystem.open(transcript.filePath, { flag: "r" }).pipe(
631 Effect.flatMap((file) =>
632 Effect.gen(function* () {
633 const decoder = new TextDecoder();
634 let remaining = "";
635 let bytesRead = 0;
636 let recordsRead = 0;
637 const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size);
638 const reserveRecord = () => {
639 if (
640 recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT ||
641 budget.recordsRemaining === 0
642 ) {
643 budget.truncated = true;
644 return false;
645 }
646 recordsRead += 1;
647 budget.recordsRemaining -= 1;
648 return true;
649 };
650 const readLastRecord = () => {
651 const record = remaining + decoder.decode();
652 return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim());
653 };
654
655 while (bytesRead < maxBytes) {
656 if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) {
657 budget.truncated = true;
658 return null;
659 }
660 const readSize = Math.min(
661 METADATA_READ_BYTES,
662 maxBytes - bytesRead,
663 budget.bytesRemaining,
664 );
665 budget.operationsRemaining -= 1;
666 budget.bytesRemaining -= readSize;
667 const next = yield* file.readAlloc(readSize);
668 if (Option.isNone(next)) {
669 return readLastRecord();
670 }
671
672 bytesRead += next.value.byteLength;
673 remaining += decoder.decode(next.value, { stream: true });
674 const lines = remaining.split("\n");
675 remaining = lines.pop() ?? "";
676
677 for (const line of lines) {
678 if (!reserveRecord()) return null;
679 const cwd = extractCwd(line.trim());
680 if (cwd !== null) return cwd;
681 }
682 }
683
684 if (bytesRead < transcript.size) {
685 budget.truncated = true;
686 return null;
687 }
688 return readLastRecord();
689 }),
690 ),
691 ),
692 ).pipe(Effect.orElseSucceed(() => null));
693 });
694
695 /** Check the open file before and after reading, without reading past its reserved byte budget. */
696 const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* (
697 filePath: string,
698 expected: ReturnType<typeof transcriptIdentity>,
699 ) {
700 if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null;
701
702 return yield* Effect.scoped(
703 fileSystem.open(filePath, { flag: "r" }).pipe(
704 Effect.flatMap((file) =>
705 Effect.gen(function* () {
706 if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) {
707 return null;
708 }
709 const decoder = new TextDecoder();
710 let contents = "";
711 let bytesRead = 0;
712
713 while (bytesRead < expected.size) {
714 const next = yield* file.readAlloc(
715 Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead),
716 );
717 if (Option.isNone(next)) {
718 return null;
719 }
720
721 bytesRead += next.value.byteLength;
722 contents += decoder.decode(next.value, { stream: true });
723 }
724
725 return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))
726 ? contents + decoder.decode()
727 : null;
728 }),
729 ),
730 ),
731 ).pipe(Effect.orElseSucceed(() => null));
732 });
733
734 /**
735 * Resolve the Claude config directory the CLI would use, matching the
736 * precedence the spawned CLI sees: the instance's `homePath` (exported as
737 * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the
738 * environment, then `~/.claude`.
739 */
740 const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => {
741 const configured = homePath.trim();
742 if (configured.length > 0) {
743 return path.resolve(expandHomePath(configured));
744 }
745 const fromEnvironment = environmentHome?.trim() ?? "";
746 if (fromEnvironment.length > 0) {
747 return path.resolve(expandHomePath(fromEnvironment));
748 }
749 return path.join(NodeOS.homedir(), ".claude");
750 };
751
752 const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")(
753 function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) {
754 const projectsDir = path.join(homePath, "projects");
755 let operationsRemaining = operationBudget;
756 let truncated = false;
757 const readDirectory = (directory: string) => {
758 if (operationsRemaining <= 0) {
759 truncated = true;
760 return Effect.succeed<ReadonlyArray<string>>([]);
761 }
762 operationsRemaining -= 1;
763 return listDirectory(directory);
764 };
765 const projectDirectories = yield* readDirectory(projectsDir);
766 const transcripts: Array<TranscriptCandidate> = [];
767
768 for (const projectDirectory of projectDirectories) {
769 if (operationsRemaining <= 0) {
770 truncated = true;
771 break;
772 }
773 const directory = path.join(projectsDir, projectDirectory);
774 const directoryTranscripts = (yield* readDirectory(directory))
775 .filter((entry) => entry.endsWith(".jsonl"))
776 .map((entry) => path.join(directory, entry));
777
778 for (const filePath of directoryTranscripts) {
779 if (operationsRemaining <= 0) {
780 truncated = true;
781 break;
782 }
783 operationsRemaining -= 1;
784 const stats = yield* statOption(filePath);
785 if (
786 Option.isNone(stats) ||
787 stats.value.type !== "File" ||
788 Option.isNone(stats.value.mtime)
789 ) {
790 continue;
791 }
792 transcripts.push({
793 filePath,
794 mtimeMs: stats.value.mtime.value.getTime(),
795 providerInstanceId,
796 size: Number(stats.value.size),
797 });
798 }
799 }
800 return { transcripts, truncated };
801 },
802 );
803
804 const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")(
805 function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) {
806 const sessionsDir = path.join(homePath, "sessions");
807
808 const transcripts: Array<TranscriptCandidate> = [];
809 let operationsRemaining = operationBudget;
810 let truncated = false;
811 const readDirectory = (directory: string) => {
812 if (operationsRemaining <= 0) {
813 truncated = true;
814 return Effect.succeed<ReadonlyArray<string>>([]);
815 }
816 operationsRemaining -= 1;
817 return listDirectory(directory);
818 };
819 // Date-partitioned directories sort chronologically, so walking them in
820 // reverse spends each home's share of the operation budget on recent sessions.
821 for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) {
822 if (operationsRemaining <= 0) {
823 truncated = true;
824 break;
825 }
826 for (const month of (yield* readDirectory(path.join(sessionsDir, year)))
827 .toSorted()
828 .toReversed()) {
829 if (operationsRemaining <= 0) {
830 truncated = true;
831 break;
832 }
833 for (const day of (yield* readDirectory(path.join(sessionsDir, year, month)))
834 .toSorted()
835 .toReversed()) {
836 if (operationsRemaining <= 0) {
837 truncated = true;
838 break;
839 }
840 const directory = path.join(sessionsDir, year, month, day);
841 for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) {
842 if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue;
843 if (operationsRemaining <= 0) {
844 truncated = true;
845 break;
846 }
847 const filePath = path.join(directory, entry);
848 operationsRemaining -= 1;
849 const stats = yield* statOption(filePath);
850 if (
851 Option.isSome(stats) &&
852 stats.value.type === "File" &&
853 Option.isSome(stats.value.mtime)
854 ) {
855 transcripts.push({
856 filePath,
857 mtimeMs: stats.value.mtime.value.getTime(),
858 providerInstanceId,
859 size: Number(stats.value.size),
860 });
861 }
862 }
863 }
864 }
865 }
866 return { transcripts, truncated };
867 },
868 );
869
870 const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* (
871 source: AgentSessionSource,
872 transcripts: ReadonlyArray<TranscriptCandidate>,
873 budget: MetadataReadBudget,
874 ) {
875 const byOwnerAndCwd = new Map<
876 string,
877 {
878 cwd: string;
879 providerInstanceId: ProviderInstanceId;
880 lastActiveAtMs: number;
881 transcripts: Array<{ filePath: string; mtimeMs: number }>;
882 }
883 >();
884
885 for (const transcript of transcripts) {
886 const cwd = yield* readCwd(transcript, budget);
887 if (cwd === null) continue;
888 const key = `${transcript.providerInstanceId}\0${cwd}`;
889 const existing = byOwnerAndCwd.get(key);
890 if (existing) {
891 existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs);
892 existing.transcripts.push(transcript);
893 } else {
894 byOwnerAndCwd.set(key, {
895 cwd,
896 providerInstanceId: transcript.providerInstanceId,
897 lastActiveAtMs: transcript.mtimeMs,
898 transcripts: [transcript],
899 });
900 }
901 }
902
903 return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({
904 cwd: group.cwd,
905 source,
906 providerInstanceId: group.providerInstanceId,
907 threadCount: group.transcripts.length,
908 lastActiveAtMs: group.lastActiveAtMs,
909 transcripts: group.transcripts,
910 }));
911 });
912
913 const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () {
914 const settings = yield* serverSettings.getSettings.pipe(
915 Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })),
916 );
917
918 const raw: Array<RawCandidate> = [];
919 let truncated = false;
920
921 for (const source of ["claudeAgent", "codex"] as const) {
922 const instances: Array<{
923 readonly instanceId: ProviderInstanceId;
924 readonly config: ProviderInstanceConfig;
925 }> = Object.entries(settings.providerInstances)
926 .filter(
927 ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance),
928 )
929 .map(([instanceId, config]) => ({
930 instanceId: ProviderInstanceId.make(instanceId),
931 config,
932 }));
933 if (!Object.hasOwn(settings.providerInstances, source)) {
934 const legacyInstance = {
935 instanceId: ProviderInstanceId.make(source),
936 config: {
937 driver: ProviderDriverKind.make(source),
938 config: settings.providers[source],
939 },
940 };
941 if (resolveProviderInstanceEnabled(legacyInstance.config)) {
942 instances.push(legacyInstance);
943 }
944 }
945
946 // A shared home contains one copy of each session. Prefer the built-in
947 // instance as its owner, then keep configured order for custom accounts.
948 instances.sort((left, right) => {
949 const leftDefault = left.instanceId === source ? 0 : 1;
950 const rightDefault = right.instanceId === source ? 0 : 1;
951 return leftDefault - rightDefault;
952 });
953 const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = [];
954 const seenHomes = new Set<string>();
955 for (const { instanceId, config: instance } of instances) {
956 const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME";
957 const environmentHome =
958 instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ??
959 hostEnvironment[homeVariable];
960
961 let homePath: string;
962 if (source === "claudeAgent") {
963 const config = decodeClaudeSettings(instance.config ?? {});
964 if (Option.isNone(config)) continue;
965 homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome);
966 } else {
967 const config = decodeCodexSettings(instance.config ?? {});
968 if (Option.isNone(config)) continue;
969 const codexSettings =
970 config.value.homePath.trim().length === 0 &&
971 config.value.shadowHomePath.trim().length === 0 &&
972 environmentHome?.trim()
973 ? { ...config.value, homePath: environmentHome }
974 : config.value;
975 const layout = yield* resolveCodexHomeLayout(codexSettings).pipe(
976 Effect.provideService(Path.Path, path),
977 );
978 homePath = layout.sharedHomePath;
979 }
980
981 const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`;
982 if (seenHomes.has(homeKey)) continue;
983 seenHomes.add(homeKey);
984 homes.push({ homePath, providerInstanceId: instanceId });
985 }
986
987 const transcriptCandidates: Array<TranscriptCandidate> = [];
988 const baseOperationBudget = Math.floor(
989 MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length),
990 );
991 const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length);
992 for (const [index, home] of homes.entries()) {
993 const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0);
994 if (operationBudget === 0) {
995 truncated = true;
996 continue;
997 }
998 const discovered = yield* source === "claudeAgent"
999 ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget)
1000 : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget);
1001 truncated ||= discovered.truncated;
1002 transcriptCandidates.push(...discovered.transcripts);
1003 }
1004
1005 transcriptCandidates.sort(
1006 (left, right) =>
1007 right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath),
1008 );
1009 if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) {
1010 truncated = true;
1011 }
1012 // Give each account a turn before taking another file from the same home.
1013 const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates);
1014 const metadataBudget: MetadataReadBudget = {
1015 bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE,
1016 operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE,
1017 recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE,
1018 truncated: false,
1019 };
1020 raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget)));
1021 truncated ||= metadataBudget.truncated;
1022 }
1023
1024 return { candidates: raw, truncated };
1025 });
1026
1027 let cachedCandidates: ReadonlyArray<RawCandidate> | null = null;
1028
1029 const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () {
1030 const { candidates: raw, truncated } = yield* collectCandidates();
1031 cachedCandidates = raw;
1032
1033 // Filesystem identity merges symlinks and case aliases without collapsing
1034 // distinct case-sensitive directories.
1035 const merged = new Map<
1036 string,
1037 {
1038 path: string;
1039 sources: Array<AgentSessionSource>;
1040 threadCount: number;
1041 lastActiveAtMs: number | null;
1042 }
1043 >();
1044 const directoryKeys = new Map<string, string>();
1045
1046 for (const candidate of raw) {
1047 const expanded = expandHomePath(candidate.cwd.trim());
1048 if (!path.isAbsolute(expanded)) continue;
1049 const resolved = path.resolve(expanded);
1050 if (isExcludedProjectPath(resolved)) continue;
1051 let key = directoryKeys.get(resolved);
1052 if (key === undefined) {
1053 const stats = yield* statOption(resolved);
1054 // Directories that no longer exist can't be imported.
1055 if (Option.isNone(stats) || stats.value.type !== "Directory") {
1056 directoryKeys.set(resolved, "");
1057 continue;
1058 }
1059 const realPath = yield* fileSystem
1060 .realPath(resolved)
1061 .pipe(Effect.orElseSucceed(() => resolved));
1062 // A symlink can point into the worktrees directory even when its own
1063 // spelling doesn't; check again with links resolved.
1064 if (isExcludedProjectPath(realPath)) {
1065 key = "";
1066 } else {
1067 key = yield* directoryIdentity(resolved, stats.value);
1068 }
1069 directoryKeys.set(resolved, key);
1070 }
1071 if (key === "") continue;
1072
1073 const existing = merged.get(key);
1074 if (!existing) {
1075 merged.set(key, {
1076 path: resolved,
1077 sources: [candidate.source],
1078 threadCount: candidate.threadCount,
1079 lastActiveAtMs: candidate.lastActiveAtMs,
1080 });
1081 continue;
1082 }
1083 if (!existing.sources.includes(candidate.source)) {
1084 existing.sources.push(candidate.source);
1085 }
1086 existing.threadCount += candidate.threadCount;
1087 existing.lastActiveAtMs =
1088 existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null
1089 ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs)
1090 : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs);
1091 }
1092
1093 // Resolve persisted roots too. A project and a transcript can name
1094 // different symlinks to the same directory.
1095 const shellSnapshot = yield* projectionSnapshotQuery
1096 .getShellSnapshot()
1097 .pipe(
1098 Effect.mapError(
1099 (cause) => new AgentSessionScanError({ operation: "read-projects", cause }),
1100 ),
1101 );
1102 const importedProjectsByRoot = new Map<string, (typeof shellSnapshot.projects)[number]>();
1103 for (const project of shellSnapshot.projects) {
1104 const projectRoot = path.resolve(expandHomePath(project.workspaceRoot));
1105 importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project);
1106 importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project);
1107 }
1108
1109 const candidates: Array<AgentSessionProjectCandidate> = [];
1110 for (const [key, entry] of merged.entries()) {
1111 // Keep the path key for missing roots and use filesystem identity for
1112 // aliases that resolve to the same directory.
1113 const importedProject =
1114 importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ??
1115 importedProjectsByRoot.get(key);
1116 const candidatePath = importedProject?.workspaceRoot ?? entry.path;
1117 candidates.push({
1118 path: candidatePath,
1119 title: path.basename(candidatePath) || candidatePath,
1120 ...(importedProject === undefined ? {} : { projectId: importedProject.id }),
1121 sources: entry.sources,
1122 threadCount: entry.threadCount,
1123 lastActiveAt:
1124 entry.lastActiveAtMs === null
1125 ? null
1126 : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),
1127 alreadyImported: importedProject !== undefined,
1128 });
1129 }
1130
1131 // Newest first, undated candidates last.
1132 candidates.sort((left, right) => {
1133 if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path);
1134 if (left.lastActiveAt === null) return 1;
1135 if (right.lastActiveAt === null) return -1;
1136 return right.lastActiveAt.localeCompare(left.lastActiveAt);
1137 });
1138
1139 return {
1140 candidates,
1141 scannedAt: DateTime.formatIso(yield* DateTime.now),
1142 ...(truncated ? { truncated: true } : {}),
1143 };
1144 });
1145
1146 const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* (
1147 workspaceRoot: string,
1148 completedSources: ReadonlyArray<AgentSessionImportSource>,
1149 ) {
1150 const root = path.resolve(expandHomePath(workspaceRoot));
1151 const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
1152 if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty;
1153 const rootIdentity = yield* directoryIdentity(root);
1154 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
1155 const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS;
1156
1157 const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates;
1158 cachedCandidates = candidates;
1159
1160 const eligibleTranscripts: Array<{
1161 readonly candidate: RawCandidate;
1162 readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number };
1163 }> = [];
1164 for (const candidate of candidates) {
1165 const expanded = expandHomePath(candidate.cwd.trim());
1166 if (!path.isAbsolute(expanded)) continue;
1167 const resolved = path.resolve(expanded);
1168 if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue;
1169
1170 for (const transcript of candidate.transcripts) {
1171 if (
1172 transcript.mtimeMs === null ||
1173 transcript.mtimeMs < cutoffMs ||
1174 transcript.mtimeMs > nowMs
1175 ) {
1176 continue;
1177 }
1178 eligibleTranscripts.push({
1179 candidate,
1180 transcript: { ...transcript, mtimeMs: transcript.mtimeMs },
1181 });
1182 }
1183 }
1184
1185 eligibleTranscripts.sort((left, right) => {
1186 if (left.transcript.mtimeMs !== right.transcript.mtimeMs) {
1187 return right.transcript.mtimeMs - left.transcript.mtimeMs;
1188 }
1189 return left.transcript.filePath.localeCompare(right.transcript.filePath);
1190 });
1191
1192 const completedByFile = Map.groupBy(
1193 completedSources,
1194 (source) => `${source.providerInstanceId}\0${source.filePath}`,
1195 );
1196 const importedSessions = new Set<string>();
1197 let bytesRemaining = MAX_IMPORT_BYTES;
1198 let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS;
1199 let recordsRemaining = MAX_IMPORT_RECORDS;
1200 return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe(
1201 Stream.mapEffect(({ candidate, transcript }) =>
1202 Effect.gen(function* () {
1203 const completed = completedByFile.get(
1204 `${candidate.providerInstanceId}\0${transcript.filePath}`,
1205 );
1206 if (
1207 completed === undefined &&
1208 (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0)
1209 ) {
1210 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1211 }
1212 const stats = yield* statOption(transcript.filePath);
1213 if (Option.isNone(stats) || stats.value.type !== "File") {
1214 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1215 }
1216 const identity = transcriptIdentity(transcript.filePath, stats.value);
1217 const completedSource = completed?.find(
1218 (source) =>
1219 source.provider === candidate.source && sameTranscriptIdentity(source, identity),
1220 );
1221 if (completedSource !== undefined) {
1222 const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`;
1223 if (importedSessions.has(sessionKey)) return Option.none<AgentSessionRecentThread>();
1224 importedSessions.add(sessionKey);
1225 return Option.some<AgentSessionRecentThread>({
1226 _tag: "AlreadyImported",
1227 source: completedSource,
1228 });
1229 }
1230 if (
1231 transcriptsRemaining === 0 ||
1232 recordsRemaining === 0 ||
1233 identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES ||
1234 identity.size > bytesRemaining
1235 ) {
1236 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1237 }
1238 // Reserve the whole file even if its read or parse fails.
1239 transcriptsRemaining -= 1;
1240 bytesRemaining -= identity.size;
1241 const contents = yield* readTranscript(transcript.filePath, identity);
1242 if (contents === null) {
1243 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1244 }
1245 const lines = splitTranscriptRecords(contents, recordsRemaining + 1);
1246 if (lines.length > recordsRemaining) {
1247 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1248 }
1249 recordsRemaining -= lines.length;
1250
1251 // A stable replacement file can belong to a different project than the cached candidate.
1252 let snapshotCwd: string | null = null;
1253 for (const line of lines) {
1254 snapshotCwd = extractCwd(line);
1255 if (snapshotCwd !== null) break;
1256 }
1257 if (snapshotCwd === null) {
1258 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1259 }
1260 const expandedCwd = expandHomePath(snapshotCwd.trim());
1261 if (
1262 !path.isAbsolute(expandedCwd) ||
1263 (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity
1264 ) {
1265 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1266 }
1267
1268 const parsedThread = parseAgentSessionTranscript(
1269 {
1270 contents,
1271 source: candidate.source,
1272 providerInstanceId: candidate.providerInstanceId,
1273 fallbackSessionId: path.basename(transcript.filePath, ".jsonl"),
1274 lastActiveAtMs: transcript.mtimeMs,
1275 },
1276 lines,
1277 );
1278 if (parsedThread === null) {
1279 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1280 }
1281
1282 const source: AgentSessionImportSource = {
1283 ...identity,
1284 provider: parsedThread.source,
1285 providerInstanceId: parsedThread.providerInstanceId,
1286 providerSessionId: parsedThread.providerSessionId,
1287 };
1288 const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`;
1289 if (importedSessions.has(sessionKey)) {
1290 return Option.some<AgentSessionRecentThread>({ _tag: "Duplicate", source });
1291 }
1292 importedSessions.add(sessionKey);
1293 return Option.some<AgentSessionRecentThread>({
1294 _tag: "Importable",
1295 thread: parsedThread,
1296 source,
1297 });
1298 }),
1299 ),
1300 Stream.map(Option.toArray),
1301 Stream.flattenIterable,
1302 );
1303 });
1304
1305 const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = (
1306 workspaceRoot,
1307 completedSources = [],
1308 ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources));
1309
1310 return AgentSessionScanner.of({ scan, recentThreads });
1311});
1312
1313export const layer = Layer.effect(AgentSessionScanner, make);
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
...@@ -41,6 +41,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -41,6 +41,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
41 getProjectShellById: (projectId) =>41 getProjectShellById: (projectId) =>
42 Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),42 Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
43 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),43 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
44 getImportedAgentSessionSources: () => Effect.die("unused"),
44 getThreadCheckpointContext: () => Effect.die("unused"),45 getThreadCheckpointContext: () => Effect.die("unused"),
45 getFullThreadDiffContext: () => Effect.die("unused"),46 getFullThreadDiffContext: () => Effect.die("unused"),
46 getThreadRuntimeContext: () => Effect.die("unused"),47 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/provider/Drivers/ClaudeDriver.ts+6-1
...@@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
27import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";27import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
28import { ServerConfig } from "../../config.ts";28import { ServerConfig } from "../../config.ts";
29import { expandHomePath } from "../../pathExpansion.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";30import { ServerSettingsService } from "../../serverSettings.ts";
30import { ProviderDriverError } from "../Errors.ts";31import { ProviderDriverError } from "../Errors.ts";
31import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";32import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
...@@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
119 driverKind: DRIVER_KIND,120 driverKind: DRIVER_KIND,
120 instanceId,121 instanceId,
121 });122 });
122 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;123 const effectiveConfig = {
124 ...config,
125 enabled,
126 binaryPath: expandHomePath(config.binaryPath),
127 } satisfies ClaudeSettings;
123 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(128 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
124 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {129 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
125 binaryPath: effectiveConfig.binaryPath,130 binaryPath: effectiveConfig.binaryPath,
apps/server/src/provider/Drivers/CodexDriver.ts+2
...@@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
33import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";33import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
34import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";34import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
35import { ServerConfig } from "../../config.ts";35import { ServerConfig } from "../../config.ts";
36import { expandHomePath } from "../../pathExpansion.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";37import { ServerSettingsService } from "../../serverSettings.ts";
37import { ProviderDriverError } from "../Errors.ts";38import { ProviderDriverError } from "../Errors.ts";
38import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";39import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
...@@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
157 const effectiveConfig = {158 const effectiveConfig = {
158 ...config,159 ...config,
159 enabled,160 enabled,
161 binaryPath: expandHomePath(config.binaryPath),
160 homePath: homeLayout.effectiveHomePath ?? "",162 homePath: homeLayout.effectiveHomePath ?? "",
161 } satisfies CodexSettings;163 } satisfies CodexSettings;
162 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(164 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
apps/server/src/provider/Layers/CodexAdapter.test.ts+1
...@@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea...@@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea
222222
223const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {223const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
224 upsert: () => Effect.void,224 upsert: () => Effect.void,
225 recordImportedTranscript: () => Effect.die("unused"),
225 getProvider: () =>226 getProvider: () =>
226 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),227 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
227 getBinding: () => Effect.succeed(Option.none()),228 getBinding: () => Effect.succeed(Option.none()),
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+1
...@@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
516516
517const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {517const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
518 upsert: () => Effect.void,518 upsert: () => Effect.void,
519 recordImportedTranscript: () => Effect.die("unused"),
519 getProvider: () =>520 getProvider: () =>
520 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),521 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
521 getBinding: () => Effect.succeed(Option.none()),522 getBinding: () => Effect.succeed(Option.none()),
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+132-1
...@@ -24,7 +24,6 @@...@@ -24,7 +24,6 @@
24 */24 */
25import { describe, expect, it } from "@effect/vitest";25import { describe, expect, it } from "@effect/vitest";
26import * as NodeServices from "@effect/platform-node/NodeServices";26import * as NodeServices from "@effect/platform-node/NodeServices";
27import * as Path from "effect/Path";
28import {27import {
29 type ClaudeSettings,28 type ClaudeSettings,
30 type CodexSettings,29 type CodexSettings,
...@@ -35,9 +34,12 @@ import {...@@ -35,9 +34,12 @@ import {
35 type ProviderInstanceConfigMap,34 type ProviderInstanceConfigMap,
36 ProviderInstanceId,35 ProviderInstanceId,
37} from "@t3tools/contracts";36} from "@t3tools/contracts";
37import { isHostWindows } from "@t3tools/shared/hostProcess";
38import * as DateTime from "effect/DateTime";38import * as DateTime from "effect/DateTime";
39import * as Effect from "effect/Effect";39import * as Effect from "effect/Effect";
40import * as FileSystem from "effect/FileSystem";
40import * as Layer from "effect/Layer";41import * as Layer from "effect/Layer";
42import * as Path from "effect/Path";
41import * as Stream from "effect/Stream";43import * as Stream from "effect/Stream";
42import { HttpClient, HttpClientResponse } from "effect/unstable/http";44import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4345
...@@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";...@@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
45import type { BuiltInDriversEnv } from "../builtInDrivers.ts";47import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
46import { AntigravityInstallation } from "../AntigravityInstallation.ts";48import { AntigravityInstallation } from "../AntigravityInstallation.ts";
47import { ServerConfig } from "../../config.ts";49import { ServerConfig } from "../../config.ts";
50import { expandHomePath } from "../../pathExpansion.ts";
48import { ServerSettingsService } from "../../serverSettings.ts";51import { ServerSettingsService } from "../../serverSettings.ts";
49import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";52import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
50import { CodexDriver } from "../Drivers/CodexDriver.ts";53import { CodexDriver } from "../Drivers/CodexDriver.ts";
...@@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSetti...@@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSetti
139 ...overrides,142 ...overrides,
140});143});
141144
145const makeTildeProviderFixtures = Effect.fn(
146 "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures",
147)(function* () {
148 const fileSystem = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const homePath = expandHomePath("~");
151 const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({
152 directory: homePath,
153 prefix: ".t3-provider-path-test-",
154 });
155 const codexPath = path.join(fixtureDir, "codex");
156 const claudePath = path.join(fixtureDir, "claude");
157 const claudeHomePath = path.join(fixtureDir, "claude-home");
158 const codexScriptPath = path.join(fixtureDir, "codex-script.json");
159 const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures");
160
161 yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath);
162 yield* fileSystem.copyFile(
163 path.join(codexFixtureDir, "codexCollabMockPeer.mjs"),
164 path.join(fixtureDir, "codexCollabMockPeer.mjs"),
165 );
166 yield* fileSystem.copyFile(
167 path.join(codexFixtureDir, "codexMultiAgentWire.json"),
168 path.join(fixtureDir, "codexMultiAgentWire.json"),
169 );
170 yield* fileSystem.writeFileString(
171 codexScriptPath,
172 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer.
173 JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }),
174 );
175 yield* fileSystem.chmod(codexPath, 0o755);
176
177 yield* fileSystem.writeFileString(
178 claudePath,
179 [
180 "#!/usr/bin/env node",
181 'import * as NodeReadline from "node:readline";',
182 'if (process.argv.includes("--version")) {',
183 ' process.stdout.write("claude 2.1.219\\n");',
184 " process.exit(0);",
185 "}",
186 "const lines = NodeReadline.createInterface({ input: process.stdin });",
187 'lines.on("line", (line) => {',
188 " const message = JSON.parse(line);",
189 ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
190 " process.stdout.write(JSON.stringify({",
191 ' type: "control_response",',
192 " response: {",
193 ' subtype: "success",',
194 " request_id: message.request_id,",
195 " response: {",
196 " commands: [], agents: [], models: [],",
197 ' output_style: "default", available_output_styles: ["default"],',
198 ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },',
199 " },",
200 " },",
201 ' }) + "\\n");',
202 "});",
203 "setInterval(() => {}, 1_000);",
204 "",
205 ].join("\n"),
206 );
207 yield* fileSystem.chmod(claudePath, 0o755);
208 yield* fileSystem.makeDirectory(claudeHomePath);
209
210 const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`;
211 return {
212 codexBinaryPath: asTildePath(codexPath),
213 claudeBinaryPath: asTildePath(claudePath),
214 claudeHomePath,
215 codexScriptPath,
216 };
217});
218
142describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {219describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
143 // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch220 // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch
144 // directory. `Layer.merge` just unions requirements, so we have to push221 // directory. `Layer.merge` just unions requirements, so we have to push
...@@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
261 }).pipe(Effect.provide(testLayer)),338 }).pipe(Effect.provide(testLayer)),
262 );339 );
263340
341 it.live("runs Codex and Claude readiness probes from configured tilde paths", () =>
342 Effect.gen(function* () {
343 if (yield* isHostWindows) return;
344
345 const fixtures = yield* makeTildeProviderFixtures();
346
347 const codexId = ProviderInstanceId.make("codex_tilde");
348 const claudeId = ProviderInstanceId.make("claude_tilde");
349 const configMap: ProviderInstanceConfigMap = {
350 [codexId]: {
351 driver: ProviderDriverKind.make("codex"),
352 enabled: true,
353 environment: [
354 {
355 name: "T3_CODEX_COLLAB_SCRIPT",
356 value: fixtures.codexScriptPath,
357 sensitive: false,
358 },
359 ],
360 config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }),
361 },
362 [claudeId]: {
363 driver: ProviderDriverKind.make("claudeAgent"),
364 enabled: true,
365 config: makeClaudeConfig({
366 enabled: true,
367 binaryPath: fixtures.claudeBinaryPath,
368 homePath: fixtures.claudeHomePath,
369 }),
370 },
371 };
372
373 const { registry } = yield* makeProviderInstanceRegistry({
374 drivers: [CodexDriver, ClaudeDriver],
375 configMap,
376 });
377 const codex = yield* registry.getInstance(codexId);
378 const claude = yield* registry.getInstance(claudeId);
379 expect(codex).toBeDefined();
380 expect(claude).toBeDefined();
381
382 const [codexSnapshot, claudeSnapshot] = yield* Effect.all(
383 [codex!.snapshot.refresh, claude!.snapshot.refresh],
384 { concurrency: "unbounded" },
385 );
386 expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" });
387 expect(claudeSnapshot).toMatchObject({
388 status: "ready",
389 installed: true,
390 version: "2.1.219",
391 });
392 }).pipe(Effect.provide(testLayer)),
393 );
394
264 it.live(395 it.live(
265 "shadows instances whose driver is not registered in this build without failing boot",396 "shadows instances whose driver is not registered in this build without failing boot",
266 () =>397 () =>
apps/server/src/provider/Layers/ProviderService.test.ts+1
...@@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) =>...@@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) =>
4271const boundedListing = makeProviderServiceLayer({4271const boundedListing = makeProviderServiceLayer({
4272 directory: {4272 directory: {
4273 upsert: () => Effect.void,4273 upsert: () => Effect.void,
4274 recordImportedTranscript: () => Effect.die("unused"),
4274 getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"),4275 getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"),
4275 getBinding,4276 getBinding,
4276 listThreadIds,4277 listThreadIds,
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+224-42
...@@ -4,9 +4,13 @@ import * as NodeOS from "node:os";...@@ -4,9 +4,13 @@ import * as NodeOS from "node:os";
4import * as NodePath from "node:path";4import * as NodePath from "node:path";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { ProviderDriverKind, ThreadId } from "@t3tools/contracts";7import {
8import { it, assert } from "@effect/vitest";8 ProviderDriverKind,
9import { assertSome } from "@effect/vitest/utils";9 ProviderInstanceId,
10 ThreadId,
11 type AgentSessionImportSource,
12} from "@t3tools/contracts";
13import { assert, expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";16import * as Option from "effect/Option";
...@@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim...@@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim
20import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";24import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
21import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";25import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
2226
27const importedSource = {
28 provider: "codex",
29 providerInstanceId: ProviderInstanceId.make("codex"),
30 providerSessionId: "provider-session",
31 filePath: "/tmp/provider-session.jsonl",
32 size: 100,
33 mtimeMs: 1_000,
34 device: 1,
35 inode: 123,
36 birthtimeMs: 500,
37} satisfies AgentSessionImportSource;
38
23function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {39function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {
24 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer));40 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer));
25 return Layer.mergeAll(41 return Layer.mergeAll(
42 persistenceLayer,
26 runtimeRepositoryLayer,43 runtimeRepositoryLayer,
27 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),44 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
28 NodeServices.layer,45 NodeServices.layer,
...@@ -30,7 +47,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli...@@ -30,7 +47,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
30}47}
3148
32it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {49it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {
33 it("upserts and reads thread bindings", () =>50 it.effect("upserts and reads thread bindings", () =>
34 Effect.gen(function* () {51 Effect.gen(function* () {
35 const directory = yield* ProviderSessionDirectory;52 const directory = yield* ProviderSessionDirectory;
36 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;53 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
...@@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
3956
40 yield* directory.upsert({57 yield* directory.upsert({
41 provider: ProviderDriverKind.make("codex"),58 provider: ProviderDriverKind.make("codex"),
59 providerInstanceId: ProviderInstanceId.make("codex"),
42 threadId: initialThreadId,60 threadId: initialThreadId,
43 });61 });
4462
45 const provider = yield* directory.getProvider(initialThreadId);63 const provider = yield* directory.getProvider(initialThreadId);
46 assert.equal(provider, "codex");64 assert.equal(provider, "codex");
47 const resolvedBinding = yield* directory.getBinding(initialThreadId);65 const resolvedBinding = yield* directory.getBinding(initialThreadId);
48 assertSome(resolvedBinding, {66 expect(Option.getOrThrow(resolvedBinding)).toMatchObject({
49 threadId: initialThreadId,67 threadId: initialThreadId,
50 provider: ProviderDriverKind.make("codex"),68 provider: ProviderDriverKind.make("codex"),
51 });69 });
...@@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
5775
58 yield* directory.upsert({76 yield* directory.upsert({
59 provider: ProviderDriverKind.make("codex"),77 provider: ProviderDriverKind.make("codex"),
78 providerInstanceId: ProviderInstanceId.make("codex"),
60 threadId: nextThreadId,79 threadId: nextThreadId,
61 });80 });
62 const updatedBinding = yield* directory.getBinding(nextThreadId);81 const updatedBinding = yield* directory.getBinding(nextThreadId);
...@@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
74 }93 }
7594
76 const threadIds = yield* directory.listThreadIds();95 const threadIds = yield* directory.listThreadIds();
77 assert.deepEqual(threadIds, [nextThreadId]);96 expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId]));
78 }));97 }),
98 );
7999
80 it("persists runtime fields and merges payload updates", () =>100 it.effect("persists runtime fields and merges payload updates", () =>
81 Effect.gen(function* () {101 Effect.gen(function* () {
82 const directory = yield* ProviderSessionDirectory;102 const directory = yield* ProviderSessionDirectory;
83 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;103 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
...@@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
86106
87 yield* directory.upsert({107 yield* directory.upsert({
88 provider: ProviderDriverKind.make("codex"),108 provider: ProviderDriverKind.make("codex"),
109 providerInstanceId: ProviderInstanceId.make("codex"),
89 threadId,110 threadId,
90 status: "starting",111 status: "starting",
91 resumeCursor: {112 resumeCursor: {
...@@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
99120
100 yield* directory.upsert({121 yield* directory.upsert({
101 provider: ProviderDriverKind.make("codex"),122 provider: ProviderDriverKind.make("codex"),
123 providerInstanceId: ProviderInstanceId.make("codex"),
102 threadId,124 threadId,
103 status: "running",125 status: "running",
104 runtimePayload: {126 runtimePayload: {
...@@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
120 activeTurnId: "turn-1",142 activeTurnId: "turn-1",
121 });143 });
122 }144 }
123 }));145 }),
146 );
147
148 it.effect("keeps the existing binding when an insert conflicts", () =>
149 Effect.gen(function* () {
150 const directory = yield* ProviderSessionDirectory;
151 const threadId = ThreadId.make("thread-insert-conflict");
152
153 yield* directory.upsert({
154 provider: ProviderDriverKind.make("codex"),
155 providerInstanceId: ProviderInstanceId.make("codex"),
156 threadId,
157 status: "running",
158 resumeCursor: { threadId: "active-provider-thread" },
159 });
160
161 yield* directory.upsert(
162 {
163 provider: ProviderDriverKind.make("codex"),
164 providerInstanceId: ProviderInstanceId.make("codex"),
165 threadId,
166 status: "stopped",
167 resumeCursor: { threadId: "stale-provider-thread" },
168 },
169 { onConflict: "ignore" },
170 );
171
172 const binding = yield* directory.getBinding(threadId);
173 expect(Option.getOrThrow(binding)).toMatchObject({
174 threadId,
175 status: "running",
176 resumeCursor: { threadId: "active-provider-thread" },
177 });
178 }),
179 );
180
181 it.effect("records source files without replacing the current provider session", () =>
182 Effect.gen(function* () {
183 const directory = yield* ProviderSessionDirectory;
184 const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
185 const source = { ...importedSource, providerSessionId: "record-source" };
186 const threadId = ThreadId.make(
187 `import:${source.providerInstanceId}:${source.providerSessionId}`,
188 );
189 const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" };
190 yield* directory.upsert({
191 threadId,
192 provider: ProviderDriverKind.make("claudeAgent"),
193 providerInstanceId: ProviderInstanceId.make("claude-current"),
194 status: "running",
195 resumeCursor: { resume: "current-native-session" },
196 runtimePayload,
197 });
198 const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId }));
199
200 yield* directory.recordImportedTranscript({ threadId, source });
201 const replacement = { ...source, size: 200, mtimeMs: 2_000 };
202 yield* directory.recordImportedTranscript({ threadId, source: replacement });
203 const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" };
204 yield* directory.recordImportedTranscript({ threadId, source: secondFile });
205
206 expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({
207 ...before,
208 runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] },
209 });
210 }),
211 );
212
213 it.effect("does not create a binding when recording an imported transcript", () =>
214 Effect.gen(function* () {
215 const directory = yield* ProviderSessionDirectory;
216 const threadId = ThreadId.make("import:codex:missing-source-binding");
217
218 yield* directory.recordImportedTranscript({ threadId, source: importedSource });
219
220 expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true);
221 }),
222 );
223
224 it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () =>
225 Effect.gen(function* () {
226 const directory = yield* ProviderSessionDirectory;
227 const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
228 const firstSource = { ...importedSource, providerSessionId: "stale-source" };
229 const threadId = ThreadId.make(
230 `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`,
231 );
232 yield* directory.upsert({
233 threadId,
234 provider: ProviderDriverKind.make("codex"),
235 providerInstanceId: ProviderInstanceId.make("codex"),
236 status: "stopped",
237 resumeCursor: { threadId: "original-native-session" },
238 runtimePayload: { cwd: "/tmp/stale-source-project" },
239 });
240 yield* directory.recordImportedTranscript({ threadId, source: firstSource });
241 const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId }));
242 const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" };
243 yield* directory.recordImportedTranscript({ threadId, source: secondSource });
244
245 yield* repository.upsert({
246 ...stale,
247 status: "running",
248 resumeCursor: { threadId: "new-native-session" },
249 lastSeenAt: "2026-08-24T10:00:00.000Z",
250 });
251
252 expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({
253 ...stale,
254 status: "running",
255 resumeCursor: { threadId: "new-native-session" },
256 lastSeenAt: "2026-08-24T10:00:00.000Z",
257 runtimePayload: {
258 cwd: "/tmp/stale-source-project",
259 importedTranscripts: [firstSource, secondSource],
260 },
261 });
262 }),
263 );
264
265 it.effect("reserves imported source records for the atomic recording method", () =>
266 Effect.gen(function* () {
267 const directory = yield* ProviderSessionDirectory;
268 for (const onConflict of ["update", "ignore"] as const) {
269 const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` };
270 const threadId = ThreadId.make(
271 `import:${source.providerInstanceId}:${source.providerSessionId}`,
272 );
273 const binding = {
274 threadId,
275 provider: ProviderDriverKind.make("codex"),
276 providerInstanceId: ProviderInstanceId.make("codex"),
277 };
278 yield* directory.upsert(
279 { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } },
280 { onConflict },
281 );
282 expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({
283 cwd: "/tmp/project",
284 });
285
286 yield* directory.recordImportedTranscript({ threadId, source });
287 yield* directory.upsert({ ...binding, runtimePayload: null });
288
289 expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({
290 importedTranscripts: [source],
291 });
292 }
293 }),
294 );
124295
125 it("lists persisted bindings with metadata in oldest-first order", () =>296 it.effect("lists persisted bindings with metadata in oldest-first order", () =>
126 Effect.gen(function* () {297 Effect.gen(function* () {
127 const directory = yield* ProviderSessionDirectory;298 const directory = yield* ProviderSessionDirectory;
128 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;299 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
...@@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
162 },333 },
163 });334 });
164335
165 const bindings = yield* directory.listBindings();336 const bindings = (yield* directory.listBindings()).filter(
337 (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId,
338 );
166339
167 assert.deepEqual(bindings, [340 assert.deepEqual(bindings, [
168 {341 {
169 threadId: olderThreadId,342 threadId: olderThreadId,
170 provider: ProviderDriverKind.make("claudeAgent"),343 provider: ProviderDriverKind.make("claudeAgent"),
344 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
171 adapterKey: "claudeAgent",345 adapterKey: "claudeAgent",
172 runtimeMode: "approval-required",346 runtimeMode: "approval-required",
173 status: "starting",347 status: "starting",
...@@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
182 {356 {
183 threadId: newerThreadId,357 threadId: newerThreadId,
184 provider: ProviderDriverKind.make("codex"),358 provider: ProviderDriverKind.make("codex"),
359 providerInstanceId: ProviderInstanceId.make("codex"),
185 adapterKey: "codex",360 adapterKey: "codex",
186 runtimeMode: "full-access",361 runtimeMode: "full-access",
187 status: "running",362 status: "running",
...@@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
194 },369 },
195 },370 },
196 ]);371 ]);
197 }));372 }),
373 );
198374
199 it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () =>375 it.effect(
200 Effect.gen(function* () {376 "resets adapterKey to the new provider when provider changes without an explicit adapter key",
201 const directory = yield* ProviderSessionDirectory;377 () =>
202 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;378 Effect.gen(function* () {
203 const threadId = ThreadId.make("thread-provider-change");379 const directory = yield* ProviderSessionDirectory;
380 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
381 const threadId = ThreadId.make("thread-provider-change");
204382
205 yield* runtimeRepository.upsert({383 yield* runtimeRepository.upsert({
206 threadId,384 threadId,
207 providerName: "claudeAgent",385 providerName: "claudeAgent",
208 providerInstanceId: null,386 providerInstanceId: null,
209 adapterKey: "claudeAgent",387 adapterKey: "claudeAgent",
210 runtimeMode: "full-access",388 runtimeMode: "full-access",
211 status: "running",389 status: "running",
212 lastSeenAt: "2026-01-01T00:00:00.000Z",390 lastSeenAt: "2026-01-01T00:00:00.000Z",
213 resumeCursor: null,391 resumeCursor: null,
214 runtimePayload: null,392 runtimePayload: null,
215 });393 });
216394
217 yield* directory.upsert({395 yield* directory.upsert({
218 provider: ProviderDriverKind.make("codex"),396 provider: ProviderDriverKind.make("codex"),
219 threadId,397 providerInstanceId: ProviderInstanceId.make("codex"),
220 });398 threadId,
399 });
221400
222 const runtime = yield* runtimeRepository.getByThreadId({ threadId });401 const runtime = yield* runtimeRepository.getByThreadId({ threadId });
223 assert.equal(Option.isSome(runtime), true);402 assert.equal(Option.isSome(runtime), true);
224 if (Option.isSome(runtime)) {403 if (Option.isSome(runtime)) {
225 assert.equal(runtime.value.providerName, "codex");404 assert.equal(runtime.value.providerName, "codex");
226 assert.equal(runtime.value.adapterKey, "codex");405 assert.equal(runtime.value.adapterKey, "codex");
227 }406 }
228 }));407 }),
408 );
229409
230 it("rehydrates persisted mappings across layer restart", () =>410 it.effect("rehydrates persisted mappings across layer restart", () =>
231 Effect.gen(function* () {411 Effect.gen(function* () {
232 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-"));412 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-"));
233 const dbPath = NodePath.join(tempDir, "orchestration.sqlite");413 const dbPath = NodePath.join(tempDir, "orchestration.sqlite");
...@@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
239 const directory = yield* ProviderSessionDirectory;419 const directory = yield* ProviderSessionDirectory;
240 yield* directory.upsert({420 yield* directory.upsert({
241 provider: ProviderDriverKind.make("codex"),421 provider: ProviderDriverKind.make("codex"),
422 providerInstanceId: ProviderInstanceId.make("codex"),
242 threadId,423 threadId,
243 });424 });
244 }).pipe(Effect.provide(directoryLayer));425 }).pipe(Effect.provide(directoryLayer));
...@@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
250 assert.equal(provider, "codex");431 assert.equal(provider, "codex");
251432
252 const resolvedBinding = yield* directory.getBinding(threadId);433 const resolvedBinding = yield* directory.getBinding(threadId);
253 assertSome(resolvedBinding, {434 expect(Option.getOrThrow(resolvedBinding)).toMatchObject({
254 threadId,435 threadId,
255 provider: ProviderDriverKind.make("codex"),436 provider: ProviderDriverKind.make("codex"),
256 });437 });
...@@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
267 }).pipe(Effect.provide(directoryLayer));448 }).pipe(Effect.provide(directoryLayer));
268449
269 NodeFS.rmSync(tempDir, { recursive: true, force: true });450 NodeFS.rmSync(tempDir, { recursive: true, force: true });
270 }));451 }),
452 );
271});453});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+35-20
...@@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
100 ),100 ),
101 );101 );
102102
103 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {103 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) {
104 const existing = yield* repository104 const existing = yield* repository
105 .getByThreadId({ threadId: binding.threadId })105 .getByThreadId({ threadId: binding.threadId })
106 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")));106 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")));
...@@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
126 });126 });
127 }127 }
128 yield* repository128 yield* repository
129 .upsert({129 .upsert(
130 threadId: resolvedThreadId,130 {
131 providerName: binding.provider,131 threadId: resolvedThreadId,
132 providerInstanceId,132 providerName: binding.provider,
133 adapterKey:133 providerInstanceId,
134 binding.adapterKey ??134 adapterKey:
135 (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)),135 binding.adapterKey ??
136 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",136 (providerChanged
137 status: binding.status ?? existingRuntime?.status ?? "running",137 ? binding.provider
138 lastSeenAt: now,138 : (existingRuntime?.adapterKey ?? binding.provider)),
139 resumeCursor:139 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
140 binding.resumeCursor !== undefined140 status: binding.status ?? existingRuntime?.status ?? "running",
141 ? binding.resumeCursor141 lastSeenAt: now,
142 : (existingRuntime?.resumeCursor ?? null),142 resumeCursor:
143 runtimePayload: mergeRuntimePayload(143 binding.resumeCursor !== undefined
144 existingRuntime?.runtimePayload ?? null,144 ? binding.resumeCursor
145 binding.runtimePayload,145 : (existingRuntime?.resumeCursor ?? null),
146 ),146 runtimePayload: mergeRuntimePayload(
147 })147 existingRuntime?.runtimePayload ?? null,
148 binding.runtimePayload,
149 ),
150 },
151 options,
152 )
148 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));153 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
149 });154 });
150155
...@@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
164 ),169 ),
165 );170 );
166171
172 const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = (
173 input,
174 ) =>
175 repository
176 .recordImportedTranscript(input)
177 .pipe(
178 Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")),
179 );
180
167 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>181 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
168 repository.list().pipe(182 repository.list().pipe(
169 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")),183 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")),
...@@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
184198
185 return {199 return {
186 upsert,200 upsert,
201 recordImportedTranscript,
187 getProvider,202 getProvider,
188 getBinding,203 getBinding,
189 listThreadIds,204 listThreadIds,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => {...@@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => {
217 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),217 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
218 getProjectShellById: () => Effect.die("unused"),218 getProjectShellById: () => Effect.die("unused"),
219 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),219 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
220 getImportedAgentSessionSources: () => Effect.die("unused"),
220 getThreadCheckpointContext: () => Effect.die("unused"),221 getThreadCheckpointContext: () => Effect.die("unused"),
221 getFullThreadDiffContext: () => Effect.die("unused"),222 getFullThreadDiffContext: () => Effect.die("unused"),
222 getThreadRuntimeContext: () => Effect.die("unused"),223 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/provider/ProviderInstanceEnvironment.test.ts+48-1
...@@ -1,8 +1,55 @@...@@ -1,8 +1,55 @@
1import { describe, expect, it } from "vite-plus/test";1import * as NodeOS from "node:os";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Path from "effect/Path";
27
3import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";8import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
49
5describe("mergeProviderInstanceEnvironment", () => {10describe("mergeProviderInstanceEnvironment", () => {
11 it.effect.each([
12 { value: "~/.account", tail: ".account" },
13 { value: "~\\.account\\work", tail: ".account\\work" },
14 ])("expands configured provider homes set to $value", ({ value, tail }) =>
15 Effect.gen(function* () {
16 const path = yield* Path.Path;
17 const baseEnv = {
18 CODEX_HOME: "~/.inherited-codex",
19 CLAUDE_CONFIG_DIR: "~/.inherited-claude",
20 };
21 const environment = mergeProviderInstanceEnvironment(
22 [
23 { name: "CODEX_HOME", value, sensitive: false },
24 { name: "CLAUDE_CONFIG_DIR", value, sensitive: false },
25 { name: "CUSTOM_VALUE", value, sensitive: false },
26 ],
27 baseEnv,
28 );
29
30 expect(environment).toEqual({
31 CODEX_HOME: path.join(NodeOS.homedir(), tail),
32 CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail),
33 CUSTOM_VALUE: value,
34 });
35 expect(baseEnv).toEqual({
36 CODEX_HOME: "~/.inherited-codex",
37 CLAUDE_CONFIG_DIR: "~/.inherited-claude",
38 });
39 }).pipe(Effect.provide(NodeServices.layer)),
40 );
41
42 it("leaves inherited provider homes unchanged", () => {
43 const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" };
44
45 expect(
46 mergeProviderInstanceEnvironment(
47 [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }],
48 baseEnv,
49 ),
50 ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" });
51 });
52
6 it("overrides inherited environment values and preserves empty strings", () => {53 it("overrides inherited environment values and preserves empty strings", () => {
7 expect(54 expect(
8 mergeProviderInstanceEnvironment(55 mergeProviderInstanceEnvironment(
apps/server/src/provider/ProviderInstanceEnvironment.ts+7-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import type { ProviderInstanceEnvironment } from "@t3tools/contracts";1import type { ProviderInstanceEnvironment } from "@t3tools/contracts";
22
3import { expandHomePath } from "../pathExpansion.ts";
4
3export function mergeProviderInstanceEnvironment(5export function mergeProviderInstanceEnvironment(
4 environment: ProviderInstanceEnvironment | undefined,6 environment: ProviderInstanceEnvironment | undefined,
5 baseEnv: NodeJS.ProcessEnv = process.env,7 baseEnv: NodeJS.ProcessEnv = process.env,
...@@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment(...@@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment(
1012
11 const next: NodeJS.ProcessEnv = { ...baseEnv };13 const next: NodeJS.ProcessEnv = { ...baseEnv };
12 for (const variable of environment) {14 for (const variable of environment) {
13 next[variable.name] = variable.value;15 // Child processes do not apply shell expansion to environment values.
16 next[variable.name] =
17 variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR"
18 ? expandHomePath(variable.value)
19 : variable.value;
14 }20 }
15 return next;21 return next;
16}22}
apps/server/src/provider/Services/ProviderSessionDirectory.ts+12
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type {1import type {
2 AgentSessionImportSource,
2 ProviderInstanceId,3 ProviderInstanceId,
3 ProviderDriverKind,4 ProviderDriverKind,
4 ProviderSessionRuntimeStatus,5 ProviderSessionRuntimeStatus,
...@@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError =...@@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError =
40 | ProviderValidationError41 | ProviderValidationError
41 | ProviderSessionDirectoryPersistenceError;42 | ProviderSessionDirectoryPersistenceError;
4243
44export interface ProviderSessionDirectoryUpsertOptions {
45 readonly onConflict?: "update" | "ignore";
46}
47
43export interface ProviderSessionDirectoryShape {48export interface ProviderSessionDirectoryShape {
44 readonly upsert: (49 readonly upsert: (
45 binding: ProviderRuntimeBinding,50 binding: ProviderRuntimeBinding,
51 options?: ProviderSessionDirectoryUpsertOptions,
46 ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>;52 ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>;
4753
54 /** Record an imported file without changing the current provider session. */
55 readonly recordImportedTranscript: (input: {
56 readonly threadId: ThreadId;
57 readonly source: AgentSessionImportSource;
58 }) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;
59
48 readonly getProvider: (60 readonly getProvider: (
49 threadId: ThreadId,61 threadId: ThreadId,
50 ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>;62 ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>;
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+8
...@@ -57,6 +57,14 @@ rl.on("line", (line) => {...@@ -57,6 +57,14 @@ rl.on("line", (line) => {
57 });57 });
58 return;58 return;
59 }59 }
60 if (method === "account/read") {
61 write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } });
62 return;
63 }
64 if (method === "skills/list" || method === "model/list") {
65 write({ id, result: { data: [] } });
66 return;
67 }
60 if (method === "thread/start") {68 if (method === "thread/start") {
61 write({ id, result: fixture.responses.threadStart });69 write({ id, result: fixture.responses.threadStart });
62 return;70 return;
apps/server/src/relay/AgentAwarenessRelay.test.ts+87-11
...@@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
138 expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId);138 expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId);
139 });139 });
140140
141 it("does not publish start intents, streaming content, or non-awareness activity events", () => {141 it("does not publish imported, start-intent, streaming, or non-awareness events", () => {
142 const now = "2026-05-25T00:00:00.000Z";142 const now = "2026-05-25T00:00:00.000Z";
143 const base = {143 const base = {
144 sequence: 1,144 sequence: 1,
...@@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
147 aggregateKind: "thread",147 aggregateKind: "thread",
148 aggregateId: "thread-1" as ThreadId,148 aggregateId: "thread-1" as ThreadId,
149 occurredAt: now,149 occurredAt: now,
150 metadata: {},
150 };151 };
151152
152 expect(153 expect(
...@@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
202 },203 },
203 } as unknown as OrchestrationEvent),204 } as unknown as OrchestrationEvent),
204 ).toBe(false);205 ).toBe(false);
206 expect(
207 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
208 ...base,
209 type: "thread.created",
210 metadata: { historyImport: true },
211 payload: { threadId: "thread-1" as ThreadId },
212 } as unknown as OrchestrationEvent),
213 ).toBe(false);
214 expect(
215 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
216 ...base,
217 type: "thread.settled",
218 metadata: { historyImport: true },
219 payload: { threadId: "thread-1" as ThreadId },
220 } as unknown as OrchestrationEvent),
221 ).toBe(false);
222 expect(
223 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
224 ...base,
225 type: "thread.created",
226 payload: { threadId: "thread-1" as ThreadId },
227 } as unknown as OrchestrationEvent),
228 ).toBe(true);
229 expect(
230 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
231 ...base,
232 type: "thread.settled",
233 payload: { threadId: "thread-1" as ThreadId },
234 } as unknown as OrchestrationEvent),
235 ).toBe(true);
205 });236 });
206237
207 it("deduplicates awareness state updates whose only change is their event timestamp", () => {238 it("deduplicates awareness state updates whose only change is their event timestamp", () => {
...@@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
400 }),431 }),
401 );432 );
402433
403 it.effect("keeps the orchestration listener armed until relay config is installed", () =>434 it.effect("keeps the listener armed and skips imported thread work", () =>
404 Effect.scoped(435 Effect.scoped(
405 Effect.gen(function* () {436 Effect.gen(function* () {
406 const events = yield* Queue.unbounded<OrchestrationEvent>();437 const events = yield* Queue.unbounded<OrchestrationEvent>();
407 const threadShellRequested = yield* Deferred.make<void>();438 const threadShellRequested = yield* Deferred.make<void>();
439 const releaseThreadShell = yield* Deferred.make<void>();
440 const threadShellRequests: Array<ThreadId> = [];
441 let fetchCallCount = 0;
408 const secrets = makeMemorySecretStore();442 const secrets = makeMemorySecretStore();
409 const now = "2026-05-25T00:00:00.000Z";443 const now = "2026-05-25T00:00:00.000Z";
410 const projectId = "project-1" as ProjectId;444 const projectId = "project-1" as ProjectId;
411 const threadId = "thread-1" as ThreadId;445 const threadId = "thread-1" as ThreadId;
446 const importedThreadId = "import:codex:session-1" as ThreadId;
412 const environmentId = "env-1" as EnvironmentId;447 const environmentId = "env-1" as EnvironmentId;
413448
449 const originalFetch = globalThis.fetch;
450 globalThis.fetch = (() => {
451 fetchCallCount += 1;
452 return Promise.resolve(Response.json({ ok: true, deliveries: [] }));
453 }) as unknown as typeof fetch;
454 yield* Effect.addFinalizer(() =>
455 Effect.sync(() => {
456 globalThis.fetch = originalFetch;
457 }),
458 );
459
414 const project = {460 const project = {
415 id: projectId,461 id: projectId,
416 title: "T3 Code",462 title: "T3 Code",
...@@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
473 getShellSnapshot: () =>519 getShellSnapshot: () =>
474 Effect.succeed({520 Effect.succeed({
475 snapshotSequence: 1,521 snapshotSequence: 1,
476 projects: [project],522 projects: [],
477 threads: [thread],523 threads: [],
478 updatedAt: now,524 updatedAt: now,
479 } satisfies OrchestrationShellSnapshot),525 } satisfies OrchestrationShellSnapshot),
480 getThreadShellById: () =>526 getThreadShellById: (requestedThreadId: ThreadId) =>
481 Deferred.succeed(threadShellRequested, undefined).pipe(527 Effect.gen(function* () {
482 Effect.ignore,528 threadShellRequests.push(requestedThreadId);
483 Effect.as(Option.some(thread)),529 if (requestedThreadId !== threadId) return Option.none();
484 ),530 yield* Deferred.succeed(threadShellRequested, undefined);
531 yield* Deferred.await(releaseThreadShell);
532 return Option.some(thread);
533 }),
485 getProjectShellById: () => Effect.succeed(Option.some(project)),534 getProjectShellById: () => Effect.succeed(Option.some(project)),
486 } as unknown as ProjectionSnapshotQueryShape;535 } as unknown as ProjectionSnapshotQueryShape;
487536
...@@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
511 yield* Effect.gen(function* () {560 yield* Effect.gen(function* () {
512 const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;561 const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
513 yield* relay.start();562 yield* relay.start();
514 yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1");563 yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test");
515 yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential");564 yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential");
516 yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true");565 yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true");
517 yield* Queue.offer(events, {566 yield* Queue.offer(events, {
518 type: "thread.activity-appended",567 type: "thread.created",
519 sequence: 1,568 sequence: 1,
569 eventId: "evt-import-created",
570 commandId: CommandId.make("cmd-import-created"),
571 aggregateKind: "thread",
572 aggregateId: importedThreadId,
573 metadata: { historyImport: true },
574 payload: { threadId: importedThreadId },
575 occurredAt: now,
576 } as unknown as OrchestrationEvent);
577 yield* Queue.offer(events, {
578 type: "thread.settled",
579 sequence: 2,
580 eventId: "evt-import-settled",
581 commandId: CommandId.make("cmd-import-settled"),
582 aggregateKind: "thread",
583 aggregateId: importedThreadId,
584 metadata: { historyImport: true },
585 payload: { threadId: importedThreadId },
586 occurredAt: now,
587 } as unknown as OrchestrationEvent);
588 yield* Queue.offer(events, {
589 type: "thread.activity-appended",
590 sequence: 3,
520 eventId: "evt-1",591 eventId: "evt-1",
521 commandId: CommandId.make("cmd-1"),592 commandId: CommandId.make("cmd-1"),
522 aggregateKind: "thread",593 aggregateKind: "thread",
523 aggregateId: threadId,594 aggregateId: threadId,
524 actor: { kind: "server" },595 actor: { kind: "server" },
596 metadata: {},
525 payload: {597 payload: {
526 threadId,598 threadId,
527 activity: {599 activity: {
...@@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
532 } as unknown as OrchestrationEvent);604 } as unknown as OrchestrationEvent);
533605
534 yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds"));606 yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds"));
607 expect(threadShellRequests).toEqual([threadId]);
608 expect(fetchCallCount).toBe(0);
609 yield* Deferred.succeed(releaseThreadShell, undefined);
535 }).pipe(610 }).pipe(
536 Effect.provide(611 Effect.provide(
537 AgentAwarenessRelay.layer.pipe(612 AgentAwarenessRelay.layer.pipe(
...@@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
692 aggregateKind: "thread",767 aggregateKind: "thread",
693 aggregateId: threadId,768 aggregateId: threadId,
694 actor: { kind: "server" },769 actor: { kind: "server" },
770 metadata: {},
695 payload: {771 payload: {
696 threadId,772 threadId,
697 activity: {773 activity: {
apps/server/src/relay/AgentAwarenessRelay.ts+3
...@@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null {...@@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null {
67}67}
6868
69export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean {69export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean {
70 if (event.metadata.historyImport === true) {
71 return false;
72 }
70 switch (event.type) {73 switch (event.type) {
71 case "thread.message-sent":74 case "thread.message-sent":
72 case "thread.turn-start-requested":75 case "thread.turn-start-requested":
apps/server/src/server.test.ts+203
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 type ProviderInstallState,36 type ProviderInstallState,
37 ProviderSetupError,37 ProviderSetupError,
38 ResolvedKeybindingRule,38 ResolvedKeybindingRule,
39 type ServerLifecycleStreamEvent,
39 ThreadId,40 ThreadId,
40 TurnId,41 TurnId,
41 WS_METHODS,42 WS_METHODS,
...@@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(...@@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(
91const decodeTransferShellSnapshot = Schema.decodeUnknownEffect(92const decodeTransferShellSnapshot = Schema.decodeUnknownEffect(
92 Schema.fromJsonString(OrchestrationShellSnapshot),93 Schema.fromJsonString(OrchestrationShellSnapshot),
93);94);
95const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
9496
95import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";97import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
96import * as ServerConfig from "./config.ts";98import * as ServerConfig from "./config.ts";
...@@ -127,6 +129,7 @@ import {...@@ -127,6 +129,7 @@ import {
127 AntigravityInstallationError,129 AntigravityInstallationError,
128} from "./provider/AntigravityInstallation.ts";130} from "./provider/AntigravityInstallation.ts";
129import type { ProviderInstance } from "./provider/ProviderDriver.ts";131import type { ProviderInstance } from "./provider/ProviderDriver.ts";
132import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
130import { ProviderAdapterRequestError } from "./provider/Errors.ts";133import { ProviderAdapterRequestError } from "./provider/Errors.ts";
131import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";134import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
132import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";135import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
...@@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: {...@@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: {
513 projectSetupScriptRunner?: Partial<516 projectSetupScriptRunner?: Partial<
514 ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]517 ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]
515 >;518 >;
519 providerSessionDirectory?: Partial<
520 ProviderSessionDirectory.ProviderSessionDirectory["Service"]
521 >;
516 terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>;522 terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>;
517 orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>;523 orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>;
518 threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>;524 threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>;
...@@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: {...@@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: {
785 managedDirectory: "unused-test-antigravity-runtime",791 managedDirectory: "unused-test-antigravity-runtime",
786 ...options?.layers?.antigravityInstallation,792 ...options?.layers?.antigravityInstallation,
787 }),793 }),
794 Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({
795 upsert: () => Effect.void,
796 getBinding: () => Effect.succeed(Option.none()),
797 listThreadIds: () => Effect.succeed([]),
798 listBindings: () => Effect.succeed([]),
799 ...options?.layers?.providerSessionDirectory,
800 }),
788 ),801 ),
789 ),802 ),
790 Layer.provide(803 Layer.provide(
...@@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: {...@@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: {
973 }),986 }),
974 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),987 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
975 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),988 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
989 getImportedAgentSessionSources: () => Effect.succeed([]),
976 getThreadCheckpointContext: () => Effect.succeed(Option.none()),990 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
977 ...options?.layers?.projectionSnapshotQuery,991 ...options?.layers?.projectionSnapshotQuery,
978 }),992 }),
...@@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5332 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5346 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5333 );5347 );
53345348
5349 it.effect("keeps agent session import project failures structured over websocket rpc", () =>
5350 Effect.gen(function* () {
5351 yield* buildAppUnderTest();
5352
5353 const projectId = ProjectId.make("missing-import-project");
5354 const wsUrl = yield* getWsServerUrl("/ws");
5355 const error = yield* Effect.scoped(
5356 withWsRpcClient(wsUrl, (client) =>
5357 client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip),
5358 ),
5359 );
5360
5361 assert.equal(error._tag, "AgentSessionImportProjectNotFoundError");
5362 if (error._tag === "AgentSessionImportProjectNotFoundError") {
5363 assert.equal(error.projectId, projectId);
5364 }
5365 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5366 );
5367
5368 it.effect("returns scanner skip counts over websocket rpc", () =>
5369 Effect.gen(function* () {
5370 const fileSystem = yield* FileSystem.FileSystem;
5371 const path = yield* Path.Path;
5372 const codexHome = yield* fileSystem.makeTempDirectoryScoped({
5373 prefix: "t3-agent-import-rpc-codex-",
5374 });
5375 const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({
5376 prefix: "t3-agent-import-rpc-workspace-",
5377 });
5378 const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31");
5379 const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl");
5380 yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true });
5381 yield* fileSystem.writeFileString(
5382 transcriptPath,
5383 encodeTestJson({
5384 timestamp: "2026-08-31T12:00:00.000Z",
5385 type: "session_meta",
5386 payload: { id: "rpc-skipped-session", cwd: workspaceRoot },
5387 }),
5388 );
5389 yield* fileSystem.utimes(transcriptPath, 0, 0);
5390
5391 const projectId = ProjectId.make("agent-import-rpc-project");
5392 const project = {
5393 id: projectId,
5394 title: "Agent import RPC",
5395 workspaceRoot,
5396 defaultModelSelection: null,
5397 scripts: [],
5398 createdAt: "2026-08-31T12:00:00.000Z",
5399 updatedAt: "2026-08-31T12:00:00.000Z",
5400 } as const;
5401 yield* buildAppUnderTest({
5402 layers: {
5403 serverSettings: {
5404 getSettings: Effect.succeed({
5405 ...DEFAULT_SERVER_SETTINGS,
5406 providerInstances: {
5407 [ProviderInstanceId.make("codex")]: {
5408 driver: ProviderDriverKind.make("codex"),
5409 config: { homePath: codexHome },
5410 },
5411 [ProviderInstanceId.make("claudeAgent")]: {
5412 driver: ProviderDriverKind.make("claudeAgent"),
5413 enabled: false,
5414 config: {},
5415 },
5416 },
5417 }),
5418 },
5419 projectionSnapshotQuery: {
5420 getProjectShellById: (requestedProjectId) =>
5421 Effect.succeed(
5422 requestedProjectId === projectId ? Option.some(project) : Option.none(),
5423 ),
5424 },
5425 },
5426 });
5427
5428 const wsUrl = yield* getWsServerUrl("/ws");
5429 const result = yield* Effect.scoped(
5430 withWsRpcClient(wsUrl, (client) =>
5431 Effect.gen(function* () {
5432 const scan = yield* client[WS_METHODS.agentSessionsScan]({});
5433 assert.deepEqual(
5434 scan.candidates.map((candidate) => candidate.path),
5435 [workspaceRoot],
5436 );
5437 return yield* client[WS_METHODS.agentSessionsImport]({ projectId });
5438 }),
5439 ),
5440 );
5441
5442 assert.deepEqual(result, { importedCount: 0, skippedCount: 1 });
5443 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5444 );
5445
5335 it.effect("uploads Codex thread feedback through websocket rpc", () =>5446 it.effect("uploads Codex thread feedback through websocket rpc", () =>
5336 Effect.gen(function* () {5447 Effect.gen(function* () {
5337 const input = {5448 const input = {
...@@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6239 }).pipe(Effect.provide(NodeHttpServer.layerTest)),6350 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6240 );6351 );
62416352
6353 it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () =>
6354 Effect.gen(function* () {
6355 const pubsub = yield* PubSub.unbounded<ServerLifecycleStreamEvent>();
6356 const streamSubscribed = yield* Deferred.make<void>();
6357 const snapshotPublished = yield* Deferred.make<void>();
6358 const bootstrapProjectId = ProjectId.make("project-bootstrap");
6359 const bootstrapThreadId = ThreadId.make("thread-bootstrap");
6360 const snapshotEvent = {
6361 version: 1 as const,
6362 sequence: 1,
6363 type: "welcome" as const,
6364 payload: {
6365 environment: testEnvironmentDescriptor,
6366 cwd: "/tmp/project",
6367 projectName: "project",
6368 bootstrapStatus: "pending" as const,
6369 },
6370 };
6371 const gapEvent = {
6372 version: 1 as const,
6373 sequence: 2,
6374 type: "welcome" as const,
6375 payload: {
6376 environment: testEnvironmentDescriptor,
6377 cwd: "/tmp/project",
6378 projectName: "project",
6379 bootstrapStatus: "complete" as const,
6380 bootstrapProjectId,
6381 bootstrapThreadId,
6382 bootstrapProjectCreated: true,
6383 bootstrapThreadCreated: true,
6384 },
6385 };
6386 const sentinelEvent = {
6387 version: 1 as const,
6388 sequence: 3,
6389 type: "ready" as const,
6390 payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor },
6391 };
6392 const liveStream = Stream.unwrap(
6393 Effect.gen(function* () {
6394 const subscription = yield* PubSub.subscribe(pubsub);
6395 yield* Deferred.succeed(streamSubscribed, undefined);
6396 return Stream.fromSubscription(subscription);
6397 }),
6398 );
6399
6400 yield* buildAppUnderTest({
6401 layers: {
6402 serverLifecycleEvents: {
6403 snapshot: PubSub.publish(pubsub, gapEvent).pipe(
6404 Effect.andThen(Deferred.succeed(snapshotPublished, undefined)),
6405 Effect.as({ sequence: 1, events: [snapshotEvent] }),
6406 ),
6407 stream: liveStream,
6408 },
6409 },
6410 });
6411
6412 yield* Effect.gen(function* () {
6413 yield* Deferred.await(snapshotPublished);
6414 yield* Deferred.await(streamSubscribed);
6415 yield* PubSub.publish(pubsub, sentinelEvent);
6416 }).pipe(Effect.forkScoped);
6417
6418 const wsUrl = yield* getWsServerUrl("/ws");
6419 const events = yield* Effect.scoped(
6420 withWsRpcClient(wsUrl, (client) =>
6421 client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect),
6422 ),
6423 );
6424
6425 const [first, second] = Array.from(events);
6426 assert.equal(first?.type, "welcome");
6427 assert.equal(first?.sequence, 1);
6428 if (first?.type !== "welcome") {
6429 assert.fail("expected the pending bootstrap event");
6430 }
6431 assert.equal(first.payload.bootstrapStatus, "pending");
6432 assert.equal(second?.type, "welcome");
6433 assert.equal(second?.sequence, 2);
6434 if (second?.type !== "welcome") {
6435 assert.fail("expected the bootstrap completion event");
6436 }
6437 assert.equal(second.payload.bootstrapStatus, "complete");
6438 assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId);
6439 assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId);
6440 assert.equal(second.payload.bootstrapProjectCreated, true);
6441 assert.equal(second.payload.bootstrapThreadCreated, true);
6442 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6443 );
6444
6242 it.effect("routes websocket rpc projects.searchEntries", () =>6445 it.effect("routes websocket rpc projects.searchEntries", () =>
6243 Effect.gen(function* () {6446 Effect.gen(function* () {
6244 const fs = yield* FileSystem.FileSystem;6447 const fs = yield* FileSystem.FileSystem;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+11
...@@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () =...@@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () =
151 ),151 ),
152 ),152 ),
153 upsert: (binding) => Effect.sync(() => upserts.push(binding)),153 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
154 recordImportedTranscript: () => Effect.die("unused"),
154 getProvider: () => Effect.die("unused"),155 getProvider: () => Effect.die("unused"),
155 listThreadIds: () => Effect.die("unused"),156 listThreadIds: () => Effect.die("unused"),
156 listBindings: () => Effect.succeed([]),157 listBindings: () => Effect.succeed([]),
...@@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)(...@@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)(
265 firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void,266 firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void,
266 ),267 ),
267 ),268 ),
269 recordImportedTranscript: () => Effect.die("unused"),
268 getProvider: () => Effect.die("unused"),270 getProvider: () => Effect.die("unused"),
269 listThreadIds: () => Effect.die("unused"),271 listThreadIds: () => Effect.die("unused"),
270 listBindings: () => Effect.succeed([]),272 listBindings: () => Effect.succeed([]),
...@@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => {...@@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => {
391 );393 );
392 },394 },
393 upsert: () => Effect.void,395 upsert: () => Effect.void,
396 recordImportedTranscript: () => Effect.die("unused"),
394 getProvider: () => Effect.die("unused"),397 getProvider: () => Effect.die("unused"),
395 listThreadIds: () => Effect.die("unused"),398 listThreadIds: () => Effect.die("unused"),
396 listBindings: () => Effect.succeed([]),399 listBindings: () => Effect.succeed([]),
...@@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure...@@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure
446 }),449 }),
447 ),450 ),
448 upsert: () => Effect.void,451 upsert: () => Effect.void,
452 recordImportedTranscript: () => Effect.die("unused"),
449 getProvider: () => Effect.die("unused"),453 getProvider: () => Effect.die("unused"),
450 listThreadIds: () => Effect.die("unused"),454 listThreadIds: () => Effect.die("unused"),
451 listBindings: () => Effect.succeed([]),455 listBindings: () => Effect.succeed([]),
...@@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio...@@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
517 ),521 ),
518 ),522 ),
519 upsert: (binding) => Effect.sync(() => upserts.push(binding)),523 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
524 recordImportedTranscript: () => Effect.die("unused"),
520 getProvider: () => Effect.die("unused"),525 getProvider: () => Effect.die("unused"),
521 listThreadIds: () => Effect.die("unused"),526 listThreadIds: () => Effect.die("unused"),
522 listBindings: () => Effect.succeed([]),527 listBindings: () => Effect.succeed([]),
...@@ -596,6 +601,7 @@ it.effect(...@@ -596,6 +601,7 @@ it.effect(
596 }),601 }),
597 ),602 ),
598 upsert: () => Effect.fail(writeFailure),603 upsert: () => Effect.fail(writeFailure),
604 recordImportedTranscript: () => Effect.die("unused"),
599 getProvider: () => Effect.die("unused"),605 getProvider: () => Effect.die("unused"),
600 listThreadIds: () => Effect.die("unused"),606 listThreadIds: () => Effect.die("unused"),
601 listBindings: () => Effect.succeed([]),607 listBindings: () => Effect.succeed([]),
...@@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure",...@@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure",
633 directory: {639 directory: {
634 getBinding: () => Effect.succeed(Option.none()),640 getBinding: () => Effect.succeed(Option.none()),
635 upsert: () => Effect.void,641 upsert: () => Effect.void,
642 recordImportedTranscript: () => Effect.die("unused"),
636 getProvider: () => Effect.die("unused"),643 getProvider: () => Effect.die("unused"),
637 listThreadIds: () => Effect.die("unused"),644 listThreadIds: () => Effect.die("unused"),
638 listBindings: () => Effect.succeed([]),645 listBindings: () => Effect.succeed([]),
...@@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot...@@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot
682 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, {689 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, {
683 getBinding: () => Effect.die("unused"),690 getBinding: () => Effect.die("unused"),
684 upsert: () => Effect.die("unused"),691 upsert: () => Effect.die("unused"),
692 recordImportedTranscript: () => Effect.die("unused"),
685 getProvider: () => Effect.die("unused"),693 getProvider: () => Effect.die("unused"),
686 listThreadIds: () => Effect.die("unused"),694 listThreadIds: () => Effect.die("unused"),
687 listBindings: () => Effect.succeed([]),695 listBindings: () => Effect.succeed([]),
...@@ -754,6 +762,7 @@ for (const scenario of [...@@ -754,6 +762,7 @@ for (const scenario of [
754 Effect.sync(() => {762 Effect.sync(() => {
755 upserts.push(binding);763 upserts.push(binding);
756 }),764 }),
765 recordImportedTranscript: () => Effect.die("unused"),
757 getProvider: () => Effect.die("unused"),766 getProvider: () => Effect.die("unused"),
758 listThreadIds: () => Effect.die("unused"),767 listThreadIds: () => Effect.die("unused"),
759 listBindings: () => Effect.succeed([]),768 listBindings: () => Effect.succeed([]),
...@@ -827,6 +836,7 @@ for (const preparedStatus of [...@@ -827,6 +836,7 @@ for (const preparedStatus of [
827 if (binding.status !== "starting" || sends.length === 0) return;836 if (binding.status !== "starting" || sends.length === 0) return;
828 yield* Deferred.succeed(cleared, undefined);837 yield* Deferred.succeed(cleared, undefined);
829 }),838 }),
839 recordImportedTranscript: () => Effect.die("unused"),
830 getProvider: () => Effect.die("unused"),840 getProvider: () => Effect.die("unused"),
831 listThreadIds: () => Effect.die("unused"),841 listThreadIds: () => Effect.die("unused"),
832 listBindings: () =>842 listBindings: () =>
...@@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", (...@@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", (
932 Effect.sync(() => {942 Effect.sync(() => {
933 binding = next;943 binding = next;
934 }),944 }),
945 recordImportedTranscript: () => Effect.die("unused"),
935 getProvider: () => Effect.die("unused"),946 getProvider: () => Effect.die("unused"),
936 listThreadIds: () => Effect.die("unused"),947 listThreadIds: () => Effect.die("unused"),
937 listBindings: () => Effect.succeed([]),948 listBindings: () => Effect.succeed([]),
apps/server/src/serverRuntimeStartup.test.ts+89
...@@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
155 ),155 ),
156 getProjectShellById: () => Effect.die("unused"),156 getProjectShellById: () => Effect.die("unused"),
157 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)),157 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)),
158 getImportedAgentSessionSources: () => Effect.die("unused"),
158 getThreadCheckpointContext: () => Effect.succeed(Option.none()),159 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
159 getFullThreadDiffContext: () => Effect.succeed(Option.none()),160 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
160 getThreadRuntimeContext: () => Effect.die("unused"),161 getThreadRuntimeContext: () => Effect.die("unused"),
...@@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
181 assert.deepStrictEqual(targets, {182 assert.deepStrictEqual(targets, {
182 bootstrapProjectId,183 bootstrapProjectId,
183 bootstrapThreadId,184 bootstrapThreadId,
185 bootstrapProjectCreated: false,
186 bootstrapThreadCreated: false,
184 });187 });
185 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);188 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
186 });189 });
...@@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
212 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),215 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
213 getProjectShellById: () => Effect.die("unused"),216 getProjectShellById: () => Effect.die("unused"),
214 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),217 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
218 getImportedAgentSessionSources: () => Effect.die("unused"),
215 getThreadCheckpointContext: () => Effect.succeed(Option.none()),219 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
216 getFullThreadDiffContext: () => Effect.succeed(Option.none()),220 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
217 getThreadRuntimeContext: () => Effect.die("unused"),221 getThreadRuntimeContext: () => Effect.die("unused"),
...@@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
237241
238 assert.equal(typeof targets.bootstrapProjectId, "string");242 assert.equal(typeof targets.bootstrapProjectId, "string");
239 assert.equal(typeof targets.bootstrapThreadId, "string");243 assert.equal(typeof targets.bootstrapThreadId, "string");
244 assert.equal(targets.bootstrapProjectCreated, true);
245 assert.equal(targets.bootstrapThreadCreated, true);
240 const commands = yield* Ref.get(dispatchCalls);246 const commands = yield* Ref.get(dispatchCalls);
241 assert.deepStrictEqual(247 assert.deepStrictEqual(
242 commands.map((command) => command.type),248 commands.map((command) => command.type),
...@@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
250 }),256 }),
251);257);
252258
259it.effect(
260 "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure",
261 () =>
262 Effect.gen(function* () {
263 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
264 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
265 Effect.provideService(ServerConfig.ServerConfig, {
266 cwd: "/tmp/startup-project",
267 autoBootstrapProjectFromCwd: true,
268 } as never),
269 Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
270 getUserInputActivity: () => Effect.die("unused"),
271 getCommandReadModel: () => Effect.die("unused"),
272 getSnapshot: () => Effect.die("unused"),
273 getShellSnapshot: () => Effect.die("unused"),
274 getArchivedShellSnapshot: () => Effect.die("unused"),
275 getSnapshotSequence: () => Effect.die("unused"),
276 getCounts: () => Effect.die("unused"),
277 getEventReplayStats: () => Effect.die("unused"),
278 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
279 getProjectShellById: () => Effect.die("unused"),
280 getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"),
281 getImportedAgentSessionSources: () => Effect.die("unused"),
282 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
283 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
284 getThreadRuntimeContext: () => Effect.die("unused"),
285 getThreadShellById: () => Effect.die("unused"),
286 getThreadDetailById: () => Effect.die("unused"),
287 getThreadDetailSnapshot: () => Effect.die("unused"),
288 searchThreads: () => Effect.succeed({ matches: [] }),
289 }),
290 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
291 readEvents: () => Stream.empty,
292 readThreadEvents: () => Stream.empty,
293 getThreadReplayStats: () => Effect.die("unused thread replay stats"),
294 dispatch: (command) =>
295 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
296 Effect.as({ sequence: 1 }),
297 ),
298 streamDomainEvents: Stream.empty,
299 subscribeDomainEvents: Effect.succeed(Stream.empty),
300 latestSequence: Effect.succeed(0),
301 } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]),
302 Effect.provide(NodeServices.layer),
303 );
304
305 assert.equal(typeof targets.bootstrapProjectId, "string");
306 assert.equal(targets.bootstrapProjectCreated, true);
307 assert.equal(targets.bootstrapThreadId, undefined);
308 assert.equal(targets.bootstrapThreadCreated, undefined);
309 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]);
310 }),
311);
312
253it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () =>313it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () =>
254 Effect.gen(function* () {314 Effect.gen(function* () {
255 const crypto = yield* Crypto.Crypto;315 const crypto = yield* Crypto.Crypto;
...@@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa...@@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
278 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),338 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
279 getProjectShellById: () => Effect.die("unused"),339 getProjectShellById: () => Effect.die("unused"),
280 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),340 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
341 getImportedAgentSessionSources: () => Effect.die("unused"),
281 getThreadCheckpointContext: () => Effect.succeed(Option.none()),342 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
282 getFullThreadDiffContext: () => Effect.succeed(Option.none()),343 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
283 getThreadRuntimeContext: () => Effect.die("unused"),344 getThreadRuntimeContext: () => Effect.die("unused"),
...@@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa...@@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
309 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);370 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
310 }).pipe(Effect.provide(NodeServices.layer)),371 }).pipe(Effect.provide(NodeServices.layer)),
311);372);
373
374it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () =>
375 Effect.gen(function* () {
376 const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(
377 Effect.fail("bootstrap failed"),
378 );
379
380 assert.deepStrictEqual(completion, { bootstrapStatus: "complete" });
381 }),
382);
383
384it.effect("completeAutoBootstrapWelcome settles unexpected defects", () =>
385 Effect.gen(function* () {
386 const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(
387 Effect.die("bootstrap defect"),
388 );
389
390 assert.deepStrictEqual(completion, { bootstrapStatus: "complete" });
391 }),
392);
393
394it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () =>
395 Effect.gen(function* () {
396 const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({}));
397
398 assert.deepStrictEqual(completion, { bootstrapStatus: "complete" });
399 }),
400);
apps/server/src/serverRuntimeStartup.ts+81-46
...@@ -194,6 +194,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -194,6 +194,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
194194
195 let bootstrapProjectId: ProjectId | undefined;195 let bootstrapProjectId: ProjectId | undefined;
196 let bootstrapThreadId: ThreadId | undefined;196 let bootstrapThreadId: ThreadId | undefined;
197 let bootstrapProjectCreated = false;
198 let bootstrapThreadCreated = false;
197199
198 if (serverConfig.autoBootstrapProjectFromCwd) {200 if (serverConfig.autoBootstrapProjectFromCwd) {
199 yield* Effect.gen(function* () {201 yield* Effect.gen(function* () {
...@@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
216 workspaceRoot: serverConfig.cwd,218 workspaceRoot: serverConfig.cwd,
217 createdAt,219 createdAt,
218 });220 });
221 bootstrapProjectId = nextProjectId;
222 bootstrapProjectCreated = true;
219 } else {223 } else {
220 nextProjectId = existingProject.value.id;224 nextProjectId = existingProject.value.id;
225 bootstrapProjectId = nextProjectId;
221 nextThreadModelSelection =226 nextThreadModelSelection =
222 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();227 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
223 }228 }
224229
225 const existingThreadId =230 yield* Effect.gen(function* () {
226 yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId);231 const existingThreadId =
227 if (Option.isNone(existingThreadId)) {232 yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId);
228 const createdAt = DateTime.formatIso(yield* DateTime.now);233 if (Option.isNone(existingThreadId)) {
229 const createdThreadId = ThreadId.make(yield* randomUUID);234 const createdAt = DateTime.formatIso(yield* DateTime.now);
230 yield* orchestrationEngine.dispatch({235 const createdThreadId = ThreadId.make(yield* randomUUID);
231 type: "thread.create",236 yield* orchestrationEngine.dispatch({
232 commandId: CommandId.make(yield* randomUUID),237 type: "thread.create",
233 threadId: createdThreadId,238 commandId: CommandId.make(yield* randomUUID),
234 projectId: nextProjectId,239 threadId: createdThreadId,
235 title: "New thread",240 projectId: nextProjectId,
236 modelSelection: nextThreadModelSelection,241 title: "New thread",
237 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,242 modelSelection: nextThreadModelSelection,
238 runtimeMode: "full-access",243 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
239 branch: null,244 runtimeMode: "full-access",
240 worktreePath: null,245 branch: null,
241 createdAt,246 worktreePath: null,
242 });247 createdAt,
243 bootstrapProjectId = nextProjectId;248 });
244 bootstrapThreadId = createdThreadId;249 bootstrapThreadId = createdThreadId;
245 } else {250 bootstrapThreadCreated = true;
246 bootstrapProjectId = nextProjectId;251 } else {
247 bootstrapThreadId = existingThreadId.value;252 bootstrapThreadId = existingThreadId.value;
248 }253 }
254 }).pipe(
255 Effect.catchCause((cause) =>
256 Cause.hasInterrupts(cause)
257 ? Effect.failCause(cause)
258 : Effect.logWarning("startup thread auto-bootstrap failed", {
259 bootstrapProjectId: nextProjectId,
260 cause,
261 }),
262 ),
263 );
249 });264 });
250 }265 }
251266
252 return {267 return {
253 ...(bootstrapProjectId ? { bootstrapProjectId } : {}),268 ...(bootstrapProjectId ? { bootstrapProjectId } : {}),
254 ...(bootstrapThreadId ? { bootstrapThreadId } : {}),269 ...(bootstrapThreadId ? { bootstrapThreadId } : {}),
270 ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}),
271 ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}),
255 } as const;272 } as const;
256});273});
257274
275export const completeAutoBootstrapWelcome = <A extends object, E, R>(
276 bootstrap: Effect.Effect<A, E, R>,
277) =>
278 bootstrap.pipe(
279 Effect.matchCauseEffect({
280 onFailure: (cause) =>
281 Cause.hasInterrupts(cause)
282 ? Effect.failCause(cause)
283 : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe(
284 Effect.as({ bootstrapStatus: "complete" as const }),
285 ),
286 onSuccess: (targets) =>
287 Effect.succeed({
288 ...targets,
289 bootstrapStatus: "complete" as const,
290 }),
291 }),
292 );
293
258const resolveStartupBrowserTarget = Effect.gen(function* () {294const resolveStartupBrowserTarget = Effect.gen(function* () {
259 const serverConfig = yield* ServerConfig.ServerConfig;295 const serverConfig = yield* ServerConfig.ServerConfig;
260 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;296 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
...@@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) =>...@@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) =>
839 runStartupPhase(875 runStartupPhase(
840 "welcome.autobootstrap",876 "welcome.autobootstrap",
841 Effect.gen(function* () {877 Effect.gen(function* () {
842 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(878 const bootstrapCompletion = yield* completeAutoBootstrapWelcome(
843 Effect.provideService(Crypto.Crypto, crypto),879 resolveAutoBootstrapWelcomeTargets.pipe(
880 Effect.provideService(Crypto.Crypto, crypto),
881 ),
882 );
883
884 yield* Effect.logDebug(
885 "startup phase: publishing completed bootstrap welcome event",
886 {
887 environmentId: environment.environmentId,
888 cwd: welcomeBase.cwd,
889 projectName: welcomeBase.projectName,
890 ...bootstrapCompletion,
891 },
844 );892 );
845 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
846 return;
847 }
848
849 yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", {
850 environmentId: environment.environmentId,
851 cwd: welcomeBase.cwd,
852 projectName: welcomeBase.projectName,
853 bootstrapProjectId: bootstrapTargets.bootstrapProjectId,
854 bootstrapThreadId: bootstrapTargets.bootstrapThreadId,
855 });
856 yield* lifecycleEvents.publish({893 yield* lifecycleEvents.publish({
857 version: 1,894 version: 1,
858 type: "welcome",895 type: "welcome",
859 payload: {896 payload: {
860 environment,897 environment,
861 ...welcomeBase,898 ...welcomeBase,
862 ...bootstrapTargets,899 ...bootstrapCompletion,
863 },900 },
864 });901 });
865 }).pipe(902 }).pipe(Effect.ignoreCause({ log: true })),
866 Effect.catch((cause) =>
867 Effect.logWarning("startup auto-bootstrap welcome failed", {
868 cause,
869 }),
870 ),
871 ),
872 ),903 ),
873 );904 );
874 }905 }
...@@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) =>...@@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) =>
914 lifecycleEvents.publish({945 lifecycleEvents.publish({
915 version: 1,946 version: 1,
916 type: "welcome",947 type: "welcome",
917 payload: { environment, ...welcomeBase },948 payload: {
949 environment,
950 ...welcomeBase,
951 bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete",
952 },
918 }),953 }),
919 );954 );
920 yield* options?.activate ?? Effect.void;955 yield* options?.activate ?? Effect.void;
apps/server/src/serverSettings.test.ts+37
...@@ -14,6 +14,7 @@ import * as Duration from "effect/Duration";...@@ -14,6 +14,7 @@ import * as Duration from "effect/Duration";
14import * as FileSystem from "effect/FileSystem";14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";18import * as PlatformError from "effect/PlatformError";
18import * as Schema from "effect/Schema";19import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";20import * as Stream from "effect/Stream";
...@@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";...@@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
22import * as ServerConfig from "./config.ts";23import * as ServerConfig from "./config.ts";
23import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";24import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
24import * as ServerSettingsModule from "./serverSettings.ts";25import * as ServerSettingsModule from "./serverSettings.ts";
26import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
2527
26const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);28const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
27const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);29const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
...@@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1102 );1104 );
1103 }).pipe(Effect.provide(makeServerSettingsLayer())),1105 }).pipe(Effect.provide(makeServerSettingsLayer())),
1104 );1106 );
1107
1108 it.effect("materializes provider secrets for terminal environment resolution", () =>
1109 Effect.gen(function* () {
1110 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1111 const serverConfig = yield* ServerConfig.ServerConfig;
1112 const fileSystem = yield* FileSystem.FileSystem;
1113 const path = yield* Path.Path;
1114 const instanceId = ProviderInstanceId.make("codex_terminal");
1115
1116 yield* serverSettings.updateSettings({
1117 providerInstances: {
1118 [instanceId]: {
1119 driver: ProviderDriverKind.make("codex"),
1120 environment: [
1121 { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true },
1122 ],
1123 config: { homePath: "~/.codex-terminal" },
1124 },
1125 },
1126 });
1127
1128 const environment = yield* resolveProviderInstanceTerminalEnvironment({
1129 serverSettings,
1130 path,
1131 rawProviderInstanceId: instanceId,
1132 env: undefined,
1133 });
1134 const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath);
1135
1136 assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret");
1137 assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/);
1138 assert.notInclude(persisted, "sk-terminal-secret");
1139 assert.include(persisted, '"valueRedacted": true');
1140 }).pipe(Effect.provide(makeServerSettingsLayer())),
1141 );
1105});1142});
apps/server/src/terminal/Manager.test.ts+412
...@@ -7,9 +7,14 @@ import {...@@ -7,9 +7,14 @@ import {
7 type TerminalMetadataStreamEvent,7 type TerminalMetadataStreamEvent,
8 type TerminalOpenInput,8 type TerminalOpenInput,
9 type TerminalRestartInput,9 type TerminalRestartInput,
10 ProviderDriverKind,
11 ProviderInstanceId,
12 ServerSettingsError,
13 TerminalProviderInstanceNotFoundError,
10} from "@t3tools/contracts";14} from "@t3tools/contracts";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as Data from "effect/Data";16import * as Data from "effect/Data";
17import * as Deferred from "effect/Deferred";
13import * as Duration from "effect/Duration";18import * as Duration from "effect/Duration";
14import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
15import * as Encoding from "effect/Encoding";20import * as Encoding from "effect/Encoding";
...@@ -23,11 +28,16 @@ import * as Path from "effect/Path";...@@ -23,11 +28,16 @@ import * as Path from "effect/Path";
23import * as Ref from "effect/Ref";28import * as Ref from "effect/Ref";
24import * as Schedule from "effect/Schedule";29import * as Schedule from "effect/Schedule";
25import * as Scope from "effect/Scope";30import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
26import * as TestClock from "effect/testing/TestClock";32import * as TestClock from "effect/testing/TestClock";
27import { ChildProcessSpawner } from "effect/unstable/process";33import { ChildProcessSpawner } from "effect/unstable/process";
28import { expect } from "vite-plus/test";34import { expect } from "vite-plus/test";
2935
36import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
37import * as ServerConfig from "../config.ts";
38import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
30import * as ProcessRunner from "../processRunner.ts";39import * as ProcessRunner from "../processRunner.ts";
40import * as ServerSettings from "../serverSettings.ts";
31import * as TerminalManager from "./Manager.ts";41import * as TerminalManager from "./Manager.ts";
32import * as PtyAdapter from "./PtyAdapter.ts";42import * as PtyAdapter from "./PtyAdapter.ts";
3343
...@@ -215,6 +225,9 @@ interface CreateManagerOptions {...@@ -215,6 +225,9 @@ interface CreateManagerOptions {
215 maxRetainedInactiveSessions?: number;225 maxRetainedInactiveSessions?: number;
216 historyByteLimit?: number;226 historyByteLimit?: number;
217 ptyAdapter?: FakePtyAdapter;227 ptyAdapter?: FakePtyAdapter;
228 resolveProviderInstanceEnvironment?: Parameters<
229 typeof TerminalManager.makeWithOptions
230 >[0]["resolveProviderInstanceEnvironment"];
218}231}
219232
220interface ManagerFixture {233interface ManagerFixture {
...@@ -259,6 +272,9 @@ const createManager = (...@@ -259,6 +272,9 @@ const createManager = (
259 ...(options.maxRetainedInactiveSessions !== undefined272 ...(options.maxRetainedInactiveSessions !== undefined
260 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }273 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
261 : {}),274 : {}),
275 ...(options.resolveProviderInstanceEnvironment !== undefined
276 ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment }
277 : {}),
262 });278 });
263 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);279 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
264 const unsubscribe = yield* manager.subscribe((event) =>280 const unsubscribe = yield* manager.subscribe((event) =>
...@@ -1736,6 +1752,26 @@ it.layer(...@@ -1736,6 +1752,26 @@ it.layer(
1736 }),1752 }),
1737 );1753 );
17381754
1755 it.effect("expands provider home paths passed to setup terminals", () =>
1756 Effect.gen(function* () {
1757 const { manager, ptyAdapter } = yield* createManager(5);
1758
1759 yield* manager.open({
1760 ...openInput(),
1761 env: {
1762 CODEX_HOME: "~/.codex-work",
1763 CLAUDE_CONFIG_DIR: "~/.claude-work",
1764 CUSTOM_ACCOUNT: "~/leave-this-value-alone",
1765 },
1766 });
1767
1768 const environment = ptyAdapter.spawnInputs[0]?.env;
1769 expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/);
1770 expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/);
1771 expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone");
1772 }),
1773 );
1774
1739 it.effect("strips AppImage runtime env from terminal sessions", () =>1775 it.effect("strips AppImage runtime env from terminal sessions", () =>
1740 Effect.gen(function* () {1776 Effect.gen(function* () {
1741 const appDir = "/tmp/.mount_T3Codeabc123";1777 const appDir = "/tmp/.mount_T3Codeabc123";
...@@ -1822,6 +1858,382 @@ it.layer(...@@ -1822,6 +1858,382 @@ it.layer(
1822 }),1858 }),
1823 );1859 );
18241860
1861 it.effect("resolves a provider instance environment before spawning", () =>
1862 Effect.gen(function* () {
1863 const providerInstanceId = ProviderInstanceId.make("codex_work");
1864 const { manager, ptyAdapter } = yield* createManager(5, {
1865 env: { T3CODE_SECRET: "server-only" },
1866 resolveProviderInstanceEnvironment: (requestedId, env) =>
1867 Effect.succeed({
1868 ...env,
1869 PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong",
1870 CODEX_HOME: "/accounts/codex-work",
1871 }),
1872 });
1873
1874 const snapshot = yield* manager.open(
1875 openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }),
1876 );
1877
1878 expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value");
1879 expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work");
1880 expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1");
1881 expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined();
1882 expect(snapshot).not.toHaveProperty("env");
1883 expect(snapshot).not.toHaveProperty("providerInstanceId");
1884 }),
1885 );
1886
1887 it.effect("fails closed when a provider instance is missing", () =>
1888 Effect.gen(function* () {
1889 const providerInstanceId = ProviderInstanceId.make("deleted_instance");
1890 const { manager, ptyAdapter } = yield* createManager(5, {
1891 resolveProviderInstanceEnvironment: (requestedId) =>
1892 Effect.fail(
1893 new TerminalProviderInstanceNotFoundError({
1894 providerInstanceId: ProviderInstanceId.make(requestedId),
1895 }),
1896 ),
1897 });
1898
1899 const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip);
1900
1901 assert.deepStrictEqual(
1902 error,
1903 new TerminalProviderInstanceNotFoundError({ providerInstanceId }),
1904 );
1905 expect(ptyAdapter.spawnInputs).toHaveLength(0);
1906 }),
1907 );
1908
1909 it.effect("preserves the settings failure when provider environment resolution fails", () =>
1910 Effect.gen(function* () {
1911 const path = yield* Path.Path;
1912 const providerInstanceId = ProviderInstanceId.make("codex_work");
1913 const settingsCause = new Error("secret store read failed");
1914 const settingsError = new ServerSettingsError({
1915 settingsPath: "/test/settings.json",
1916 operation: "read-secret",
1917 providerInstanceId,
1918 environmentVariable: "OPENROUTER_API_KEY",
1919 cause: settingsCause,
1920 });
1921 const serverSettings = ServerSettings.ServerSettingsService.of({
1922 start: Effect.void,
1923 ready: Effect.void,
1924 getSettings: Effect.fail(settingsError),
1925 updateSettings: () => Effect.fail(settingsError),
1926 streamChanges: Stream.empty,
1927 subscribeChanges: Effect.succeed(Stream.empty),
1928 });
1929
1930 const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
1931 serverSettings,
1932 path,
1933 rawProviderInstanceId: providerInstanceId,
1934 env: undefined,
1935 }).pipe(Effect.flip);
1936
1937 expect(error).toMatchObject({
1938 _tag: "TerminalProviderEnvironmentError",
1939 providerInstanceId,
1940 });
1941 expect(error.cause).toBe(settingsError);
1942 expect(error.message).not.toContain(settingsError.message);
1943 expect(error.message).not.toContain("OPENROUTER_API_KEY");
1944 }),
1945 );
1946
1947 it.effect.each([
1948 {
1949 name: "Codex home",
1950 driver: "codex",
1951 variable: "CODEX_HOME",
1952 config: { homePath: "/configured/codex" },
1953 expectedHome: "/configured/codex",
1954 },
1955 {
1956 name: "Codex shadow home",
1957 driver: "codex",
1958 variable: "CODEX_HOME",
1959 config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" },
1960 expectedHome: "/configured/codex-shadow",
1961 },
1962 {
1963 name: "Claude home",
1964 driver: "claudeAgent",
1965 variable: "CLAUDE_CONFIG_DIR",
1966 config: { homePath: "/configured/claude" },
1967 expectedHome: "/configured/claude",
1968 },
1969 ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) =>
1970 Effect.gen(function* () {
1971 const path = yield* Path.Path;
1972 const serverSettings = yield* ServerSettings.ServerSettingsService;
1973 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
1974 serverSettings,
1975 path,
1976 rawProviderInstanceId: "configured_home",
1977 env: undefined,
1978 });
1979
1980 expect(environment[variable]).toBe(path.resolve(expectedHome));
1981 }).pipe(
1982 Effect.provide(
1983 ServerSettings.layerTest({
1984 providerInstances: {
1985 [ProviderInstanceId.make("configured_home")]: {
1986 driver: ProviderDriverKind.make(driver),
1987 environment: [{ name: variable, value: "~/.environment-account", sensitive: false }],
1988 config,
1989 },
1990 },
1991 }),
1992 ),
1993 ),
1994 );
1995
1996 it.effect("resolves the legacy Codex default instance", () =>
1997 Effect.gen(function* () {
1998 const path = yield* Path.Path;
1999 const serverSettings = yield* ServerSettings.ServerSettingsService;
2000 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2001 serverSettings,
2002 path,
2003 rawProviderInstanceId: "codex",
2004 env: undefined,
2005 });
2006
2007 expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/);
2008 }).pipe(
2009 Effect.provide(
2010 ServerSettings.ServerSettingsService.layerTest({
2011 providerInstances: {},
2012 providers: { codex: { homePath: "~/.codex-legacy" } },
2013 }),
2014 ),
2015 ),
2016 );
2017
2018 it.effect("resolves the legacy Claude default instance", () =>
2019 Effect.gen(function* () {
2020 const path = yield* Path.Path;
2021 const serverSettings = yield* ServerSettings.ServerSettingsService;
2022 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2023 serverSettings,
2024 path,
2025 rawProviderInstanceId: "claudeAgent",
2026 env: undefined,
2027 });
2028
2029 expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/);
2030 }).pipe(
2031 Effect.provide(
2032 ServerSettings.ServerSettingsService.layerTest({
2033 providerInstances: {},
2034 providers: { claudeAgent: { homePath: "~/.claude-legacy" } },
2035 }),
2036 ),
2037 ),
2038 );
2039
2040 it.effect("prefers an explicit default instance over legacy provider settings", () =>
2041 Effect.gen(function* () {
2042 const path = yield* Path.Path;
2043 const serverSettings = yield* ServerSettings.ServerSettingsService;
2044 const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2045 serverSettings,
2046 path,
2047 rawProviderInstanceId: "codex",
2048 env: undefined,
2049 });
2050
2051 expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/);
2052 }).pipe(
2053 Effect.provide(
2054 ServerSettings.ServerSettingsService.layerTest({
2055 providers: { codex: { homePath: "~/.codex-legacy" } },
2056 providerInstances: {
2057 [ProviderInstanceId.make("codex")]: {
2058 driver: "codex",
2059 config: { homePath: "~/.codex-explicit" },
2060 },
2061 },
2062 }),
2063 ),
2064 ),
2065 );
2066
2067 it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () =>
2068 Effect.gen(function* () {
2069 const path = yield* Path.Path;
2070 const serverSettings = yield* ServerSettings.ServerSettingsService;
2071 const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
2072 serverSettings,
2073 path,
2074 rawProviderInstanceId: "codex_unknown",
2075 env: undefined,
2076 }).pipe(Effect.flip);
2077
2078 expect(error).toMatchObject({
2079 _tag: "TerminalProviderInstanceNotFoundError",
2080 providerInstanceId: "codex_unknown",
2081 });
2082 }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())),
2083 );
2084
2085 it.effect("restarts a running terminal when the resolved provider environment changes", () =>
2086 Effect.gen(function* () {
2087 const providerInstanceId = ProviderInstanceId.make("codex_work");
2088 let providerSecret = "first-secret";
2089 const { manager, ptyAdapter } = yield* createManager(5, {
2090 resolveProviderInstanceEnvironment: () =>
2091 Effect.succeed({ PROVIDER_SECRET: providerSecret }),
2092 });
2093
2094 yield* manager.open(openInput({ providerInstanceId }));
2095 providerSecret = "second-secret";
2096 yield* manager.open(openInput({ providerInstanceId }));
2097
2098 expect(ptyAdapter.processes[0]?.killed).toBe(true);
2099 expect(ptyAdapter.spawnInputs).toHaveLength(2);
2100 expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret");
2101 }),
2102 );
2103
2104 it.effect("restarts with current provider secrets and clears bounded history", () =>
2105 Effect.gen(function* () {
2106 const serverSettings = yield* ServerSettings.ServerSettingsService;
2107 const path = yield* Path.Path;
2108 const providerInstanceId = ProviderInstanceId.make("codex_restart");
2109 const { manager, ptyAdapter, logsDir } = yield* createManager(2, {
2110 historyByteLimit: 8,
2111 resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) =>
2112 TerminalManager.resolveProviderInstanceTerminalEnvironment({
2113 serverSettings,
2114 path,
2115 rawProviderInstanceId,
2116 env,
2117 }),
2118 });
2119 const homePath = path.join(logsDir, "codex");
2120 const updateSecret = (value: string) =>
2121 serverSettings.updateSettings({
2122 providerInstances: {
2123 [providerInstanceId]: {
2124 driver: ProviderDriverKind.make("codex"),
2125 config: { homePath },
2126 environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }],
2127 },
2128 },
2129 });
2130 const input = {
2131 providerInstanceId,
2132 env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" },
2133 };
2134 const outputProcessed = yield* Deferred.make<void>();
2135 const unsubscribe = yield* manager.subscribe((event) =>
2136 event.type === "output"
2137 ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid)
2138 : Effect.void,
2139 );
2140 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2141
2142 yield* updateSecret("first-secret");
2143 yield* manager.restart(restartInput(input));
2144 const firstProcess = ptyAdapter.processes[0]!;
2145 expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret");
2146 firstProcess.emitData("discarded\nold-one\nold-two\n");
2147 yield* Deferred.await(outputProcessed);
2148 expect((yield* manager.open(openInput(input))).history).toBe("old-two\n");
2149
2150 yield* updateSecret("second-secret");
2151 const restarted = yield* manager.restart(restartInput(input));
2152
2153 expect(firstProcess.killed).toBe(true);
2154 expect(ptyAdapter.spawnInputs).toHaveLength(2);
2155 expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({
2156 PROVIDER_SECRET: "second-secret",
2157 CODEX_HOME: homePath,
2158 CLIENT_FLAG: "1",
2159 });
2160 expect(restarted.history).toBe("");
2161 expect(restarted.status).toBe("running");
2162 expect(restarted).not.toHaveProperty("env");
2163 expect(restarted).not.toHaveProperty("providerInstanceId");
2164 const logPath = yield* historyLogPath(logsDir);
2165 expect(yield* readFileString(logPath)).toBe("");
2166
2167 ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n");
2168 yield* manager.close({ threadId: "thread-1" });
2169 expect(yield* readFileString(logPath)).toBe("new-two\n");
2170 }).pipe(
2171 Effect.provide(
2172 ServerSettings.layer.pipe(
2173 Layer.provide(ServerSecretStore.layer),
2174 Layer.provide(SqlitePersistenceMemory),
2175 Layer.provide(
2176 ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }),
2177 ),
2178 ),
2179 ),
2180 ),
2181 );
2182
2183 it.effect("attaches to a running provider terminal without resolving the provider again", () =>
2184 Effect.gen(function* () {
2185 const providerInstanceId = ProviderInstanceId.make("codex_work");
2186 let providerAvailable = true;
2187 const { manager, ptyAdapter } = yield* createManager(5, {
2188 resolveProviderInstanceEnvironment: (requestedId) =>
2189 providerAvailable
2190 ? Effect.succeed({ PROVIDER_SECRET: "secret-value" })
2191 : Effect.fail(
2192 new TerminalProviderInstanceNotFoundError({
2193 providerInstanceId: ProviderInstanceId.make(requestedId),
2194 }),
2195 ),
2196 });
2197 yield* manager.open(openInput({ providerInstanceId }));
2198 providerAvailable = false;
2199 const events: TerminalAttachStreamEvent[] = [];
2200
2201 const unsubscribe = yield* manager.attachStream(
2202 { ...openInput({ providerInstanceId }), restartIfNotRunning: true },
2203 (event) => Effect.sync(() => events.push(event)),
2204 );
2205 unsubscribe();
2206
2207 expect(events[0]?.type).toBe("snapshot");
2208 expect(ptyAdapter.spawnInputs).toHaveLength(1);
2209 expect(ptyAdapter.processes[0]?.killed).toBe(false);
2210 }),
2211 );
2212
2213 it.effect("fails closed when attaching would create a missing provider terminal", () =>
2214 Effect.gen(function* () {
2215 const providerInstanceId = ProviderInstanceId.make("deleted_instance");
2216 const { manager, ptyAdapter } = yield* createManager(5, {
2217 resolveProviderInstanceEnvironment: (requestedId) =>
2218 Effect.fail(
2219 new TerminalProviderInstanceNotFoundError({
2220 providerInstanceId: ProviderInstanceId.make(requestedId),
2221 }),
2222 ),
2223 });
2224
2225 const error = yield* manager
2226 .attachStream(openInput({ providerInstanceId }), () => Effect.void)
2227 .pipe(Effect.flip);
2228
2229 assert.deepStrictEqual(
2230 error,
2231 new TerminalProviderInstanceNotFoundError({ providerInstanceId }),
2232 );
2233 expect(ptyAdapter.spawnInputs).toHaveLength(0);
2234 }),
2235 );
2236
1825 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>2237 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
1826 Effect.gen(function* () {2238 Effect.gen(function* () {
1827 if ((yield* HostProcessPlatform) === "win32") return;2239 if ((yield* HostProcessPlatform) === "win32") return;
apps/server/src/terminal/Manager.ts+182-79
...@@ -15,6 +15,8 @@ import {...@@ -15,6 +15,8 @@ import {
15 TerminalError,15 TerminalError,
16 TerminalHistoryError,16 TerminalHistoryError,
17 TerminalNotRunningError,17 TerminalNotRunningError,
18 TerminalProviderInstanceNotFoundError,
19 TerminalProviderEnvironmentError,
18 TerminalResizeError,20 TerminalResizeError,
19 TerminalSessionLookupError,21 TerminalSessionLookupError,
20 TerminalWriteError,22 TerminalWriteError,
...@@ -31,6 +33,9 @@ import {...@@ -31,6 +33,9 @@ import {
31 type TerminalSessionStatus,33 type TerminalSessionStatus,
32 type TerminalSummary,34 type TerminalSummary,
33 type TerminalWriteInput,35 type TerminalWriteInput,
36 ClaudeSettings,
37 CodexSettings,
38 ProviderInstanceId,
34} from "@t3tools/contracts";39} from "@t3tools/contracts";
35import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";40import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
36import { HostProcessPlatform } from "@t3tools/shared/hostProcess";41import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
...@@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore";...@@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore";
52import * as SynchronizedRef from "effect/SynchronizedRef";57import * as SynchronizedRef from "effect/SynchronizedRef";
5358
54import * as ServerConfig from "../config.ts";59import * as ServerConfig from "../config.ts";
60import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
61import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
62import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
63import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
64import * as ServerSettings from "../serverSettings.ts";
55import {65import {
56 increment,66 increment,
57 terminalRestartsTotal,67 terminalRestartsTotal,
58 terminalSessionsTotal,68 terminalSessionsTotal,
59} from "../observability/Metrics.ts";69} from "../observability/Metrics.ts";
70import { expandHomePath } from "../pathExpansion.ts";
60import * as ProcessRunner from "../processRunner.ts";71import * as ProcessRunner from "../processRunner.ts";
61import * as PortScanner from "../preview/PortScanner.ts";72import * as PortScanner from "../preview/PortScanner.ts";
62import * as PtyAdapter from "./PtyAdapter.ts";73import * as PtyAdapter from "./PtyAdapter.ts";
...@@ -69,6 +80,8 @@ export {...@@ -69,6 +80,8 @@ export {
69 TerminalError,80 TerminalError,
70 TerminalHistoryError,81 TerminalHistoryError,
71 TerminalNotRunningError,82 TerminalNotRunningError,
83 TerminalProviderInstanceNotFoundError,
84 TerminalProviderEnvironmentError,
72 TerminalResizeError,85 TerminalResizeError,
73 TerminalSessionLookupError,86 TerminalSessionLookupError,
74 TerminalWriteError,87 TerminalWriteError,
...@@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30;...@@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30;
86const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);99const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
87const nowIso = Effect.map(DateTime.now, DateTime.formatIso);100const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
88const MAX_TERMINAL_LABEL_LENGTH = 128;101const MAX_TERMINAL_LABEL_LENGTH = 128;
102const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
103const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
89104
90class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(105class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
91 "TerminalSubprocessCheckError",106 "TerminalSubprocessCheckError",
...@@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv(...@@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv(
1267 }1282 }
1268 if (runtimeEnv) {1283 if (runtimeEnv) {
1269 for (const [key, value] of Object.entries(runtimeEnv)) {1284 for (const [key, value] of Object.entries(runtimeEnv)) {
1270 spawnEnv[key] = value;1285 spawnEnv[key] =
1286 key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value;
1271 }1287 }
1272 }1288 }
1273 // Both PTY backends feed truecolor-capable terminal clients.1289 // Both PTY backends feed truecolor-capable terminal clients.
...@@ -1306,17 +1322,78 @@ interface TerminalManagerOptions {...@@ -1306,17 +1322,78 @@ interface TerminalManagerOptions {
1306 readonly threadId: string;1322 readonly threadId: string;
1307 readonly terminalId: string;1323 readonly terminalId: string;
1308 }) => Effect.Effect<void>;1324 }) => Effect.Effect<void>;
1325 resolveProviderInstanceEnvironment?: (
1326 providerInstanceId: string,
1327 env: Record<string, string> | undefined,
1328 ) => Effect.Effect<
1329 Record<string, string>,
1330 TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError
1331 >;
1309}1332}
13101333
1334export const resolveProviderInstanceTerminalEnvironment = Effect.fn(
1335 "terminal.resolveProviderInstanceTerminalEnvironment",
1336)(function* (input: {
1337 readonly serverSettings: ServerSettings.ServerSettingsService["Service"];
1338 readonly path: Path.Path;
1339 readonly rawProviderInstanceId: string;
1340 readonly env: Record<string, string> | undefined;
1341}) {
1342 const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId);
1343 const settings = yield* input.serverSettings.getSettings.pipe(
1344 Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })),
1345 );
1346 const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId];
1347 if (instance === undefined) {
1348 return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId });
1349 }
1350
1351 let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {});
1352 if (instance.driver === "codex") {
1353 const config = decodeCodexSettings(instance.config ?? {});
1354 if (Option.isSome(config)) {
1355 const layout = yield* resolveCodexHomeLayout(config.value).pipe(
1356 Effect.provideService(Path.Path, input.path),
1357 );
1358 if (layout.effectiveHomePath)
1359 resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath };
1360 }
1361 } else if (instance.driver === "claudeAgent") {
1362 const config = decodeClaudeSettings(instance.config ?? {});
1363 if (Option.isSome(config)) {
1364 resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe(
1365 Effect.provideService(Path.Path, input.path),
1366 );
1367 }
1368 }
1369
1370 return Object.fromEntries(
1371 Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined),
1372 );
1373});
1374
1311export const make = Effect.fn("TerminalManager.make")(function* () {1375export const make = Effect.fn("TerminalManager.make")(function* () {
1312 const { terminalLogsDir } = yield* ServerConfig.ServerConfig;1376 const { terminalLogsDir } = yield* ServerConfig.ServerConfig;
1313 const ptyAdapter = yield* PtyAdapter.PtyAdapter;1377 const ptyAdapter = yield* PtyAdapter.PtyAdapter;
1314 const portDiscovery = yield* PortScanner.PortDiscovery;1378 const portDiscovery = yield* PortScanner.PortDiscovery;
1379 const serverSettings = yield* ServerSettings.ServerSettingsService;
1380 const path = yield* Path.Path;
1381 const resolveProviderInstanceEnvironment = Effect.fn(
1382 "terminal.resolveProviderInstanceEnvironment",
1383 )((rawProviderInstanceId: string, env: Record<string, string> | undefined) =>
1384 resolveProviderInstanceTerminalEnvironment({
1385 serverSettings,
1386 path,
1387 rawProviderInstanceId,
1388 env,
1389 }),
1390 );
1315 return yield* makeWithOptions({1391 return yield* makeWithOptions({
1316 logsDir: terminalLogsDir,1392 logsDir: terminalLogsDir,
1317 ptyAdapter,1393 ptyAdapter,
1318 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,1394 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
1319 unregisterTerminal: portDiscovery.unregisterTerminal,1395 unregisterTerminal: portDiscovery.unregisterTerminal,
1396 resolveProviderInstanceEnvironment,
1320 });1397 });
1321});1398});
13221399
...@@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1339 const baseEnv = options.env ?? process.env;1416 const baseEnv = options.env ?? process.env;
1340 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));1417 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
1341 const processRunner = yield* ProcessRunner.ProcessRunner;1418 const processRunner = yield* ProcessRunner.ProcessRunner;
1419 const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")(
1420 function* <Input extends TerminalOpenInput | TerminalAttachInput | TerminalRestartInput>(
1421 input: Input,
1422 ): Effect.fn.Return<
1423 Input,
1424 TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError
1425 > {
1426 if (input.providerInstanceId === undefined) return input;
1427 const resolver = options.resolveProviderInstanceEnvironment;
1428 if (resolver === undefined) {
1429 return yield* new TerminalProviderInstanceNotFoundError({
1430 providerInstanceId: ProviderInstanceId.make(input.providerInstanceId),
1431 });
1432 }
1433 const env = yield* resolver(input.providerInstanceId, input.env);
1434 return { ...input, env };
1435 },
1436 );
1342 // One process-table snapshot per poll tick, shared across every terminal.1437 // One process-table snapshot per poll tick, shared across every terminal.
1343 // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and1438 // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
1344 // can exhaust the PID space on hosts with many sessions (#6332).1439 // can exhaust the PID space on hosts with many sessions (#6332).
...@@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2468 });2563 });
24692564
2470 const open: TerminalManager["Service"]["open"] = (input) =>2565 const open: TerminalManager["Service"]["open"] = (input) =>
2471 withThreadLock(input.threadId, openLocked(input));2566 withThreadLock(
2567 input.threadId,
2568 resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)),
2569 );
24722570
2473 const openOrAttachForStream = (input: TerminalAttachInput) =>2571 const openOrAttachForStream = (input: TerminalAttachInput) =>
2474 withThreadLock(2572 withThreadLock(
...@@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2485 });2583 });
2486 }2584 }
24872585
2488 return yield* openLocked({2586 const resolvedInput = yield* resolveLaunchInputEnvironment({
2489 ...input,2587 ...input,
2490 terminalId,2588 terminalId,
2491 cwd: input.cwd,2589 cwd: input.cwd,
2492 });2590 });
2591 return yield* openLocked(resolvedInput);
2493 }2592 }
24942593
2495 const session = existing.value;2594 const session = existing.value;
...@@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2497 const targetRows = input.rows ?? session.rows;2596 const targetRows = input.rows ?? session.rows;
24982597
2499 if (!session.process && input.cwd && input.restartIfNotRunning === true) {2598 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
2500 return yield* openLocked({2599 const resolvedInput = yield* resolveLaunchInputEnvironment({
2501 ...input,2600 ...input,
2502 terminalId,2601 terminalId,
2503 cwd: input.cwd,2602 cwd: input.cwd,
2504 });2603 });
2604 return yield* openLocked(resolvedInput);
2505 }2605 }
25062606
2507 if (2607 if (
...@@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2753 }),2853 }),
2754 );2854 );
27552855
2856 const restartResolved = (input: TerminalRestartInput) =>
2857 Effect.gen(function* () {
2858 yield* increment(terminalRestartsTotal, { scope: "thread" });
2859 const terminalId = input.terminalId;
2860 yield* assertValidCwd(input.cwd);
2861
2862 const sessionKey = toSessionKey(input.threadId, terminalId);
2863 const existingSession = yield* getSession(input.threadId, terminalId);
2864 let session: TerminalSessionState;
2865 if (Option.isNone(existingSession)) {
2866 const cols = input.cols ?? DEFAULT_OPEN_COLS;
2867 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
2868 session = {
2869 threadId: input.threadId,
2870 terminalId,
2871 cwd: input.cwd,
2872 worktreePath: input.worktreePath ?? null,
2873 status: "starting",
2874 pid: null,
2875 history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
2876 pendingHistoryControlSequence: "",
2877 pendingProcessEvents: [],
2878 pendingProcessEventIndex: 0,
2879 processEventDrainRunning: false,
2880 exitCode: null,
2881 exitSignal: null,
2882 updatedAt: yield* nowIso,
2883 eventSequence: 0,
2884 cols,
2885 rows,
2886 process: null,
2887 unsubscribeData: null,
2888 unsubscribeExit: null,
2889 hasRunningSubprocess: false,
2890 childCommandLabel: null,
2891 runtimeEnv: normalizedRuntimeEnv(input.env),
2892 };
2893 const createdSession = session;
2894 yield* modifyManagerState((state) => {
2895 const sessions = new Map(state.sessions);
2896 sessions.set(sessionKey, createdSession);
2897 return [undefined, { ...state, sessions }] as const;
2898 });
2899 yield* evictInactiveSessionsIfNeeded();
2900 } else {
2901 session = existingSession.value;
2902 yield* stopProcess(session);
2903 session.cwd = input.cwd;
2904 session.worktreePath = input.worktreePath ?? null;
2905 session.runtimeEnv = normalizedRuntimeEnv(input.env);
2906 }
2907
2908 const cols = input.cols ?? session.cols;
2909 const rows = input.rows ?? session.rows;
2910
2911 session.history.clear();
2912 session.pendingHistoryControlSequence = "";
2913 session.pendingProcessEvents = [];
2914 session.pendingProcessEventIndex = 0;
2915 session.processEventDrainRunning = false;
2916 yield* persistHistory(input.threadId, terminalId, session.history);
2917 yield* startSession(
2918 session,
2919 {
2920 threadId: input.threadId,
2921 terminalId,
2922 cwd: input.cwd,
2923 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2924 cols,
2925 rows,
2926 ...(input.env ? { env: input.env } : {}),
2927 },
2928 "restarted",
2929 );
2930 return snapshot(session);
2931 });
2932
2756 const restart: TerminalManager["Service"]["restart"] = (input) =>2933 const restart: TerminalManager["Service"]["restart"] = (input) =>
2757 withThreadLock(2934 withThreadLock(
2758 input.threadId,2935 input.threadId,
2759 Effect.gen(function* () {2936 resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)),
2760 yield* increment(terminalRestartsTotal, { scope: "thread" });
2761 const terminalId = input.terminalId;
2762 yield* assertValidCwd(input.cwd);
2763
2764 const sessionKey = toSessionKey(input.threadId, terminalId);
2765 const existingSession = yield* getSession(input.threadId, terminalId);
2766 let session: TerminalSessionState;
2767 if (Option.isNone(existingSession)) {
2768 const cols = input.cols ?? DEFAULT_OPEN_COLS;
2769 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
2770 session = {
2771 threadId: input.threadId,
2772 terminalId,
2773 cwd: input.cwd,
2774 worktreePath: input.worktreePath ?? null,
2775 status: "starting",
2776 pid: null,
2777 history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
2778 pendingHistoryControlSequence: "",
2779 pendingProcessEvents: [],
2780 pendingProcessEventIndex: 0,
2781 processEventDrainRunning: false,
2782 exitCode: null,
2783 exitSignal: null,
2784 updatedAt: yield* nowIso,
2785 eventSequence: 0,
2786 cols,
2787 rows,
2788 process: null,
2789 unsubscribeData: null,
2790 unsubscribeExit: null,
2791 hasRunningSubprocess: false,
2792 childCommandLabel: null,
2793 runtimeEnv: normalizedRuntimeEnv(input.env),
2794 };
2795 const createdSession = session;
2796 yield* modifyManagerState((state) => {
2797 const sessions = new Map(state.sessions);
2798 sessions.set(sessionKey, createdSession);
2799 return [undefined, { ...state, sessions }] as const;
2800 });
2801 yield* evictInactiveSessionsIfNeeded();
2802 } else {
2803 session = existingSession.value;
2804 yield* stopProcess(session);
2805 session.cwd = input.cwd;
2806 session.worktreePath = input.worktreePath ?? null;
2807 session.runtimeEnv = normalizedRuntimeEnv(input.env);
2808 }
2809
2810 const cols = input.cols ?? session.cols;
2811 const rows = input.rows ?? session.rows;
2812
2813 session.history.clear();
2814 session.pendingHistoryControlSequence = "";
2815 session.pendingProcessEvents = [];
2816 session.pendingProcessEventIndex = 0;
2817 session.processEventDrainRunning = false;
2818 yield* persistHistory(input.threadId, terminalId, session.history);
2819 yield* startSession(
2820 session,
2821 {
2822 threadId: input.threadId,
2823 terminalId,
2824 cwd: input.cwd,
2825 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
2826 cols,
2827 rows,
2828 ...(input.env ? { env: input.env } : {}),
2829 },
2830 "restarted",
2831 );
2832 return snapshot(session);
2833 }),
2834 );2937 );
28352938
2836 const close: TerminalManager["Service"]["close"] = (input) =>2939 const close: TerminalManager["Service"]["close"] = (input) =>
apps/server/src/ws.ts+40-1
...@@ -53,6 +53,7 @@ import {...@@ -53,6 +53,7 @@ import {
53 type RelayClientInstallProgressEvent,53 type RelayClientInstallProgressEvent,
54 ServerSelfUpdateError,54 ServerSelfUpdateError,
55 type ServerSelfUpdateProgressEvent,55 type ServerSelfUpdateProgressEvent,
56 type ServerLifecycleStreamEvent,
56 type FilesystemBrowseFailure,57 type FilesystemBrowseFailure,
57 FilesystemBrowseError,58 FilesystemBrowseError,
58 AssetWorkspaceContextNotFoundError,59 AssetWorkspaceContextNotFoundError,
...@@ -96,6 +97,7 @@ import {...@@ -96,6 +97,7 @@ import {
96} from "./observability/RpcInstrumentation.ts";97} from "./observability/RpcInstrumentation.ts";
97import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";98import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
98import * as ProviderService from "./provider/Services/ProviderService.ts";99import * as ProviderService from "./provider/Services/ProviderService.ts";
100import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
99import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";101import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
100import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";102import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";
101import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";103import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
...@@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";...@@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
119import * as GitWorkflowService from "./git/GitWorkflowService.ts";121import * as GitWorkflowService from "./git/GitWorkflowService.ts";
120import * as ReviewService from "./review/ReviewService.ts";122import * as ReviewService from "./review/ReviewService.ts";
121import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";123import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
124import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
125import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";
122import * as ServerEnvironment from "./environment/ServerEnvironment.ts";126import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
123import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";127import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
124import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";128import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
...@@ -512,6 +516,7 @@ const makeWsRpcLayer = (...@@ -512,6 +516,7 @@ const makeWsRpcLayer = (
512 const portDiscovery = yield* PortScanner.PortDiscovery;516 const portDiscovery = yield* PortScanner.PortDiscovery;
513 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;517 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
514 const providerService = yield* ProviderService.ProviderService;518 const providerService = yield* ProviderService.ProviderService;
519 const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
515 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;520 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
516 const providerAuth = yield* ProviderAuthService;521 const providerAuth = yield* ProviderAuthService;
517 const providerInstances = yield* ProviderInstanceRegistry;522 const providerInstances = yield* ProviderInstanceRegistry;
...@@ -560,6 +565,7 @@ const makeWsRpcLayer = (...@@ -560,6 +565,7 @@ const makeWsRpcLayer = (
560 return true;565 return true;
561 });566 });
562 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;567 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
568 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
563 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;569 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
564 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;570 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
565 const rpcClientIds = yield* Ref.make(new Set<RpcClientId>());571 const rpcClientIds = yield* Ref.make(new Set<RpcClientId>());
...@@ -2332,6 +2338,31 @@ const makeWsRpcLayer = (...@@ -2332,6 +2338,31 @@ const makeWsRpcLayer = (
2332 deletePendingAttachment(input.attachmentId),2338 deletePendingAttachment(input.attachmentId),
2333 { "rpc.aggregate": "workspace" },2339 { "rpc.aggregate": "workspace" },
2334 ),2340 ),
2341 [WS_METHODS.agentSessionsScan]: () =>
2342 observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, {
2343 "rpc.aggregate": "workspace",
2344 }),
2345 [WS_METHODS.agentSessionsImport]: (input) =>
2346 observeRpcEffect(
2347 WS_METHODS.agentSessionsImport,
2348 importRecentAgentThreads(input).pipe(
2349 Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner),
2350 Effect.provideService(
2351 OrchestrationEngine.OrchestrationEngineService,
2352 orchestrationEngine,
2353 ),
2354 Effect.provideService(
2355 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
2356 projectionSnapshotQuery,
2357 ),
2358 Effect.provideService(Crypto.Crypto, crypto),
2359 Effect.provideService(
2360 ProviderSessionDirectory.ProviderSessionDirectory,
2361 providerSessionDirectory,
2362 ),
2363 ),
2364 { "rpc.aggregate": "workspace" },
2365 ),
2335 [WS_METHODS.assetsCreateUrl]: (input) =>2366 [WS_METHODS.assetsCreateUrl]: (input) =>
2336 observeRpcEffect(2367 observeRpcEffect(
2337 WS_METHODS.assetsCreateUrl,2368 WS_METHODS.assetsCreateUrl,
...@@ -2746,11 +2777,18 @@ const makeWsRpcLayer = (...@@ -2746,11 +2777,18 @@ const makeWsRpcLayer = (
2746 observeRpcStreamEffect(2777 observeRpcStreamEffect(
2747 WS_METHODS.subscribeServerLifecycle,2778 WS_METHODS.subscribeServerLifecycle,
2748 Effect.gen(function* () {2779 Effect.gen(function* () {
2780 const liveBuffer = yield* Queue.unbounded<ServerLifecycleStreamEvent>();
2781 yield* Effect.forkScoped(
2782 lifecycleEvents.stream.pipe(
2783 Stream.runForEach((event) => Queue.offer(liveBuffer, event)),
2784 ),
2785 { startImmediately: true },
2786 );
2749 const snapshot = yield* lifecycleEvents.snapshot;2787 const snapshot = yield* lifecycleEvents.snapshot;
2750 const snapshotEvents = Array.from(snapshot.events).toSorted(2788 const snapshotEvents = Array.from(snapshot.events).toSorted(
2751 (left, right) => left.sequence - right.sequence,2789 (left, right) => left.sequence - right.sequence,
2752 );2790 );
2753 const liveEvents = lifecycleEvents.stream.pipe(2791 const liveEvents = Stream.fromQueue(liveBuffer).pipe(
2754 Stream.filter((event) => event.sequence > snapshot.sequence),2792 Stream.filter((event) => event.sequence > snapshot.sequence),
2755 );2793 );
2756 return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);2794 return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
...@@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
2877 previewAutomationBroker,2915 previewAutomationBroker,
2878 ).pipe(2916 ).pipe(
2879 Layer.provideMerge(RpcSerialization.layerJson),2917 Layer.provideMerge(RpcSerialization.layerJson),
2918 Layer.provide(AgentSessionScanner.layer),
2880 Layer.provide(ProviderMaintenanceRunner.layer),2919 Layer.provide(ProviderMaintenanceRunner.layer),
2881 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),2920 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
2882 // One server-lifetime service means clients share the same PR caches, and a WS2921 // One server-lifetime service means clients share the same PR caches, and a WS
apps/web/src/authBootstrap.test.ts+57
...@@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => {
310 expect(testApi.calls.session).toBe(2);310 expect(testApi.calls.session).toBe(2);
311 });311 });
312312
313 it("keeps manual token submission pending until the session is authenticated", async () => {
314 vi.useFakeTimers();
315 let authenticated = false;
316 let settled = false;
317 try {
318 const testApi = await installAuthApi({
319 session: () =>
320 authenticated
321 ? authenticatedSession(LOOPBACK_AUTH)
322 : unauthenticatedSession(LOOPBACK_AUTH),
323 browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
324 });
325 const { submitServerAuthCredential } = await import("./environments/primary");
326
327 const submission = submitServerAuthCredential("retry-token").finally(() => {
328 settled = true;
329 });
330 await vi.advanceTimersByTimeAsync(0);
331
332 expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]);
333 expect(testApi.calls.session).toBe(1);
334 expect(settled).toBe(false);
335
336 authenticated = true;
337 await vi.advanceTimersByTimeAsync(100);
338 await expect(submission).resolves.toBeUndefined();
339 expect(testApi.calls.session).toBe(2);
340 } finally {
341 vi.useRealTimers();
342 }
343 });
344
345 it("fails manual token submission when the session is not established", async () => {
346 vi.useFakeTimers();
347 try {
348 const testApi = await installAuthApi({
349 session: () => unauthenticatedSession(LOOPBACK_AUTH),
350 browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
351 });
352 const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } =
353 await import("./environments/primary/auth");
354
355 const submission = submitServerAuthCredential("retry-token");
356 const failure = submission.then(
357 () => null,
358 (error: unknown) => error,
359 );
360 await vi.advanceTimersByTimeAsync(2_000);
361
362 await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError);
363 expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]);
364 expect(testApi.calls.session).toBeGreaterThan(1);
365 } finally {
366 vi.useRealTimers();
367 }
368 });
369
313 it("rejects a blank pairing token with a structured validation error", async () => {370 it("rejects a blank pairing token with a structured validation error", async () => {
314 const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } =371 const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } =
315 await import("./environments/primary/auth");372 await import("./environments/primary/auth");
apps/web/src/browser/HostedBrowserWebview.test.tsx created+200
...@@ -0,0 +1,200 @@
1import {
2 DEFAULT_CLIENT_SETTINGS,
3 EnvironmentId,
4 FILL_PREVIEW_VIEWPORT,
5 ThreadId,
6 type ClientSettings,
7 type DesktopPreviewBridge,
8} from "@t3tools/contracts";
9import { act } from "react";
10import { create, type ReactTestRenderer } from "react-test-renderer";
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
12
13const mocks = vi.hoisted(() => ({
14 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
15 setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(),
16 createTab: vi.fn<DesktopPreviewBridge["createTab"]>(),
17 closeTab: vi.fn<DesktopPreviewBridge["closeTab"]>(),
18 registerWebview: vi.fn<DesktopPreviewBridge["registerWebview"]>(),
19 getPreviewConfig: vi.fn<DesktopPreviewBridge["getPreviewConfig"]>(),
20 activeRecordings: new Set<string>(),
21}));
22
23vi.mock("~/localApi", () => ({
24 ensureLocalApi: () => ({ persistence: mocks }),
25}));
26
27vi.mock("~/components/preview/previewBridge", () => ({
28 previewBridge: {
29 createTab: mocks.createTab,
30 closeTab: mocks.closeTab,
31 registerWebview: mocks.registerWebview,
32 getPreviewConfig: mocks.getPreviewConfig,
33 },
34}));
35
36vi.mock("~/components/preview/usePreviewBridge", () => ({
37 usePreviewBridge: () => undefined,
38}));
39
40vi.mock("./browserRecording", () => ({
41 useActiveBrowserRecordingTabIds: () => mocks.activeRecordings,
42 stopBrowserRecording: async () => null,
43}));
44
45import {
46 __resetClientSettingsPersistenceForTests,
47 ensureClientSettingsHydrated,
48} from "~/hooks/useSettings";
49import { useBrowserSurfaceStore } from "./browserSurfaceStore";
50import * as desktopTabLifetime from "./desktopTabLifetime";
51import { HostedBrowserWebview } from "./HostedBrowserWebview";
52
53let renderer: ReactTestRenderer | undefined;
54
55function deferred<A>() {
56 let resolve!: (value: A) => void;
57 let reject!: (error: unknown) => void;
58 const promise = new Promise<A>((resolvePromise, rejectPromise) => {
59 resolve = resolvePromise;
60 reject = rejectPromise;
61 });
62 return { promise, resolve, reject };
63}
64
65beforeEach(() => {
66 __resetClientSettingsPersistenceForTests();
67 useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
68 mocks.getClientSettings.mockReset();
69 mocks.setClientSettings.mockReset().mockResolvedValue(undefined);
70 mocks.createTab.mockReset().mockResolvedValue(undefined);
71 mocks.closeTab.mockReset().mockResolvedValue(undefined);
72 mocks.registerWebview.mockReset().mockResolvedValue(undefined);
73 mocks.getPreviewConfig.mockReset().mockResolvedValue({
74 partition: "persist:t3-preview-work",
75 webPreferences: "contextIsolation=yes",
76 preloadUrl: null,
77 });
78 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
79 vi.stubGlobal("window", globalThis);
80 vi.stubGlobal("navigator", { platform: "Linux" });
81 vi.stubGlobal(
82 "requestAnimationFrame",
83 vi.fn(() => 0),
84 );
85 vi.stubGlobal("cancelAnimationFrame", vi.fn());
86 vi.spyOn(console, "error").mockImplementation(() => undefined);
87});
88
89afterEach(async () => {
90 vi.useFakeTimers();
91 await act(() => renderer?.unmount());
92 renderer = undefined;
93 await vi.advanceTimersByTimeAsync(0);
94 vi.useRealTimers();
95 __resetClientSettingsPersistenceForTests();
96 useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
97 vi.restoreAllMocks();
98 vi.unstubAllGlobals();
99});
100
101describe("HostedBrowserWebview settings hydration", () => {
102 it("starts a retained background tab only after a settings read succeeds on retry", async () => {
103 const firstRead = deferred<ClientSettings | null>();
104 const retryRead = deferred<ClientSettings | null>();
105 const tabCreation = deferred<void>();
106 mocks.getClientSettings
107 .mockReturnValueOnce(firstRead.promise)
108 .mockReturnValueOnce(retryRead.promise);
109 mocks.createTab.mockReturnValueOnce(tabCreation.promise);
110 const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab");
111 const createGuest = vi.fn((_attributes: unknown) =>
112 Object.assign(new EventTarget(), { getWebContentsId: () => 41 }),
113 );
114 const threadRef = {
115 environmentId: EnvironmentId.make("host-settings-retry"),
116 threadId: ThreadId.make("thread-settings-retry"),
117 };
118 const runtimeTabId = "retained-background-tab";
119 useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId);
120
121 await act(() => {
122 renderer = create(
123 <HostedBrowserWebview
124 threadRef={threadRef}
125 tabId="server-tab"
126 runtimeTabId={runtimeTabId}
127 initialUrl="https://example.com"
128 viewport={FILL_PREVIEW_VIEWPORT}
129 pictureInPicture={false}
130 profileId="work"
131 zoomFactor={1.25}
132 />,
133 {
134 createNodeMock: (element) =>
135 element.type === "webview"
136 ? createGuest(element.props)
137 : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined },
138 },
139 );
140 });
141
142 expect(mocks.getClientSettings).toHaveBeenCalledOnce();
143 expect(acquire).not.toHaveBeenCalled();
144 expect(createGuest).not.toHaveBeenCalled();
145 expect(mocks.createTab).not.toHaveBeenCalled();
146
147 const failure = new Error("Saved settings are unavailable");
148 await act(async () => {
149 const hydration = ensureClientSettingsHydrated();
150 firstRead.reject(failure);
151 await expect(hydration).rejects.toBe(failure);
152 });
153 expect(acquire).not.toHaveBeenCalled();
154 expect(createGuest).not.toHaveBeenCalled();
155 expect(mocks.createTab).not.toHaveBeenCalled();
156
157 let retry!: Promise<void>;
158 await act(() => {
159 retry = ensureClientSettingsHydrated();
160 });
161 expect(mocks.getClientSettings).toHaveBeenCalledTimes(2);
162 expect(acquire).not.toHaveBeenCalled();
163 expect(createGuest).not.toHaveBeenCalled();
164 expect(mocks.createTab).not.toHaveBeenCalled();
165
166 await act(async () => {
167 retryRead.resolve({
168 ...DEFAULT_CLIENT_SETTINGS,
169 browserDefaultZoomFactor: 1.25,
170 browserDefaultAppearance: "dark",
171 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
172 browserDefaultProfileId: "work",
173 });
174 await retry;
175 });
176
177 expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId);
178 expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work");
179 expect(createGuest).toHaveBeenCalledOnce();
180 expect(createGuest).toHaveBeenCalledWith(
181 expect.objectContaining({
182 partition: "persist:t3-preview-work",
183 src: "https://example.com",
184 }),
185 );
186 expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, {
187 zoomFactor: 1.25,
188 colorScheme: "dark",
189 });
190 expect(mocks.registerWebview).not.toHaveBeenCalled();
191
192 await act(async () => {
193 tabCreation.resolve();
194 await tabCreation.promise;
195 });
196 expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41);
197 expect(mocks.closeTab).not.toHaveBeenCalled();
198 expect(mocks.setClientSettings).not.toHaveBeenCalled();
199 });
200});
apps/web/src/browser/HostedBrowserWebview.tsx+7-4
...@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";...@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
66
7import { previewBridge } from "~/components/preview/previewBridge";7import { previewBridge } from "~/components/preview/previewBridge";
8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { useClientSettingsHydrated } from "~/hooks/useSettings";
9import { cn, isMacPlatform } from "~/lib/utils";10import { cn, isMacPlatform } from "~/lib/utils";
1011
11import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";12import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
...@@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: {...@@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: {
66 zoomFactor,67 zoomFactor,
67 profileId,68 profileId,
68 } = props;69 } = props;
70 const clientSettingsHydrated = useClientSettingsHydrated();
69 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);71 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
70 const [initialSrc] = useState(() => initialUrl ?? "about:blank");72 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
71 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);73 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
...@@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: {...@@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: {
94 usePreviewBridge({ threadRef, tabId, runtimeTabId });96 usePreviewBridge({ threadRef, tabId, runtimeTabId });
9597
96 useEffect(() => {98 useEffect(() => {
99 if (!clientSettingsHydrated) return;
97 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;100 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
98 const lease = acquireDesktopTab(runtimeTabId);101 const lease = acquireDesktopTab(runtimeTabId);
99 tabLeaseRef.current = lease;102 tabLeaseRef.current = lease;
...@@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: {...@@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: {
101 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;104 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
102 lease.release();105 lease.release();
103 };106 };
104 }, [runtimeTabId]);107 }, [clientSettingsHydrated, runtimeTabId]);
105108
106 const [webviewGeneration, setWebviewGeneration] = useState(0);109 const [webviewGeneration, setWebviewGeneration] = useState(0);
107 const [recoverySrc, setRecoverySrc] = useState(initialSrc);110 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
...@@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: {...@@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: {
118 useEffect(() => {121 useEffect(() => {
119 const webview = webviewRef.current;122 const webview = webviewRef.current;
120 const bridge = previewBridge;123 const bridge = previewBridge;
121 if (!webview || !config || !bridge) return;124 if (!clientSettingsHydrated || !webview || !config || !bridge) return;
122 let disposed = false;125 let disposed = false;
123 let recoveryTimeout: ReturnType<typeof setTimeout> | null = null;126 let recoveryTimeout: ReturnType<typeof setTimeout> | null = null;
124 const register = () => {127 const register = () => {
...@@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: {...@@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: {
164 webview.removeEventListener("dom-ready", register);167 webview.removeEventListener("dom-ready", register);
165 webview.removeEventListener("render-process-gone", recoverGuest);168 webview.removeEventListener("render-process-gone", recoverGuest);
166 };169 };
167 }, [config, initialSrc, runtimeTabId, webviewGeneration]);170 }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]);
168171
169 const active = presentation.visible && presentation.rect !== null;172 const active = presentation.visible && presentation.rect !== null;
170 const lastRect = presentation.rect;173 const lastRect = presentation.rect;
...@@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: {...@@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: {
249 wrapper.scrollTo({ left: 0, top: 0 });252 wrapper.scrollTo({ left: 0, top: 0 });
250 }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);253 }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);
251254
252 if (!config) return null;255 if (!clientSettingsHydrated || !config) return null;
253256
254 const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;257 const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
255 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({258 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
apps/web/src/browser/browserDefaults.test.ts+23-2
...@@ -1,15 +1,17 @@...@@ -1,15 +1,17 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";2import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
33
4import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
5
4const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));6const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
57
6vi.mock("~/hooks/useSettings", () => ({8vi.mock("~/hooks/useSettings", () => ({
7 getClientSettings: () => settings.current,9 getClientSettings: () => settings.current,
8 useClientSettings: () => undefined,10 useClientSettings: () => undefined,
9 ensureClientSettingsHydrated: () => Promise.resolve(),11 ensureClientSettingsHydrated: vi.fn(async () => undefined),
10}));12}));
1113
12const { getBrowserDefaults } = await import("./browserDefaults");14const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults");
1315
14const withDefaultProfile = (browserDefaultProfileId: string) => {16const withDefaultProfile = (browserDefaultProfileId: string) => {
15 settings.current = {17 settings.current = {
...@@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => {...@@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => {
41 );43 );
42 });44 });
43});45});
46
47describe("resolveBrowserDefaults", () => {
48 it("rejects failed reads and uses the saved profile after a successful retry", async () => {
49 withDefaultProfile("work");
50 settings.current.browserDefaultZoomFactor = 1.25;
51 settings.current.browserDefaultAppearance = "dark";
52 const failure = new Error("Settings read failed");
53 vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure);
54
55 await expect(resolveBrowserDefaults()).rejects.toBe(failure);
56 await expect(resolveBrowserDefaults()).resolves.toMatchObject({
57 viewport: { _tag: "fill" },
58 zoomFactor: 1.25,
59 appearance: "dark",
60 autoShowFloatingPreview: true,
61 profileId: "work",
62 });
63 });
64});
apps/web/src/browser/browserDefaults.ts+1
...@@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults {...@@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults {
79 * Opening a preview is asynchronous anyway, and before hydration the snapshot79 * Opening a preview is asynchronous anyway, and before hydration the snapshot
80 * is the schema defaults rather than the user's — a tab opened in that window80 * is the schema defaults rather than the user's — a tab opened in that window
81 * would be born at the wrong viewport, zoom and appearance and never corrected.81 * would be born at the wrong viewport, zoom and appearance and never corrected.
82 * Read failures reject so a new tab cannot use the wrong profile or viewport.
82 */83 */
83export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {84export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {
84 await ensureClientSettingsHydrated();85 await ensureClientSettingsHydrated();
apps/web/src/browser/browserLinkTarget.test.ts+26-2
...@@ -1,6 +1,16 @@...@@ -1,6 +1,16 @@
1import { describe, expect, it } from "vite-plus/test";1import type { BrowserLinkTarget } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
23
3import { resolveLinkTarget } from "./browserLinkTarget";4import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
5
6import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget";
7
8const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget }));
9
10vi.mock("~/hooks/useSettings", () => ({
11 ensureClientSettingsHydrated: vi.fn(async () => undefined),
12 getClientSettings: () => settings,
13}));
414
5const click = { metaKey: false, ctrlKey: false };15const click = { metaKey: false, ctrlKey: false };
616
...@@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => {...@@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => {
67 }77 }
68 });78 });
69});79});
80
81describe("resolveBrowserLinkTargetPreference", () => {
82 it.each(["system", "app"] as const)(
83 "rejects failed reads instead of using the current %s preference",
84 async (preference) => {
85 settings.browserLinkTarget = preference;
86 const failure = new Error("Settings read failed");
87 vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure);
88
89 await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure);
90 await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference);
91 },
92 );
93});
apps/web/src/browser/browserLinkTarget.ts+1
...@@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean {...@@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean {
55 * hydration the snapshot is the schema default ("system"), so a link clicked55 * hydration the snapshot is the schema default ("system"), so a link clicked
56 * in the first moments after launch would ignore a persisted "app" — opening56 * in the first moments after launch would ignore a persisted "app" — opening
57 * is asynchronous anyway, so waiting costs nothing the user can see.57 * is asynchronous anyway, so waiting costs nothing the user can see.
58 * Read failures reject rather than choosing a browser without the saved preference.
58 */59 */
59export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> {60export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> {
60 await ensureClientSettingsHydrated();61 await ensureClientSettingsHydrated();
apps/web/src/browser/browserRecording.test.ts+31
...@@ -5,6 +5,8 @@ import {...@@ -5,6 +5,8 @@ import {
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
77
8import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
9
8const {10const {
9 clientSettings,11 clientSettings,
10 events,12 events,
...@@ -241,6 +243,35 @@ describe("browser recording", () => {...@@ -241,6 +243,35 @@ describe("browser recording", () => {
241 await stopBrowserRecording("recording-tab");243 await stopBrowserRecording("recording-tab");
242 });244 });
243245
246 it("clears a failed settings read before retrying recording", async () => {
247 const tabId = "settings-read-failure-tab";
248 const error = new Error("Settings read failed");
249 vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error);
250
251 await expect(startBrowserRecording(tabId)).rejects.toBe(error);
252
253 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
254 expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined();
255 expect(animationFrameCount).toBe(0);
256 expect(startScreencast).not.toHaveBeenCalled();
257 expect(stopScreencast).not.toHaveBeenCalled();
258 expect(getDisplayMedia).not.toHaveBeenCalled();
259 expect(FakeMediaRecorder.instances).toHaveLength(0);
260
261 clientSettings.browserRecordingFrameRate = 60;
262 await startBrowserRecording(tabId);
263
264 expect(getDisplayMedia).toHaveBeenCalledWith({
265 audio: false,
266 video: { frameRate: { max: 60 } },
267 });
268 await stopBrowserRecording(tabId);
269
270 expect(startScreencast).toHaveBeenCalledOnce();
271 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
272 expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined();
273 });
274
244 it("stops the native stream when MediaRecorder cleanup fails", async () => {275 it("stops the native stream when MediaRecorder cleanup fails", async () => {
245 const stopTrack = vi.fn();276 const stopTrack = vi.fn();
246 getDisplayMedia.mockResolvedValueOnce({277 getDisplayMedia.mockResolvedValueOnce({
apps/web/src/browser/browserRecording.ts+6-4
...@@ -516,10 +516,12 @@ export async function startBrowserRecording(...@@ -516,10 +516,12 @@ export async function startBrowserRecording(
516 activeRecordings.set(tabId, recording);516 activeRecordings.set(tabId, recording);
517 publishActiveRecordingTabIds();517 publishActiveRecordingTabIds();
518 try {518 try {
519 const frameRatePromise = ensureClientSettingsHydrated().then(519 await ensureClientSettingsHydrated().catch((cause: unknown) => {
520 () => getClientSettings().browserRecordingFrameRate,520 clearActiveRecording(recording);
521 );521 throw cause;
522 const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]);522 });
523 const frameRate = getClientSettings().browserRecordingFrameRate;
524 await waitForBrowserRecordingPaint();
523 const throwIfStartupCancelled = async (): Promise<void> => {525 const throwIfStartupCancelled = async (): Promise<void> => {
524 // Once a grant starts, a stop lets startup finish so the caller receives an artifact.526 // Once a grant starts, a stop lets startup finish so the caller receives an artifact.
525 // Only a contended start can be cancelled before it reaches native capture.527 // Only a contended start can be cancelled before it reaches native capture.
apps/web/src/browser/desktopTabLifetime.test.ts+34-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 DEFAULT_PREVIEW_APPEARANCE,2 DEFAULT_PREVIEW_APPEARANCE,
3 DEFAULT_PREVIEW_ZOOM_FACTOR,3 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 DEFAULT_CLIENT_SETTINGS,
4 EnvironmentId,5 EnvironmentId,
5 ThreadId,6 ThreadId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({...@@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({
21}));22}));
2223
23import { acquireDesktopTab } from "./desktopTabLifetime";24import { acquireDesktopTab } from "./desktopTabLifetime";
25import * as browserDefaults from "./browserDefaults";
26import { __setClientSettingsForTests } from "~/hooks/useSettings";
2427
25/** Client settings are unset in tests, so creation carries the schema defaults. */28/** Tests load default settings unless they select other preferences. */
26const DEFAULT_TAB_STATE = {29const DEFAULT_TAB_STATE = {
27 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,30 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
28 colorScheme: DEFAULT_PREVIEW_APPEARANCE,31 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
...@@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId";...@@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId";
3134
32describe("desktopTabLifetime", () => {35describe("desktopTabLifetime", () => {
33 beforeEach(() => {36 beforeEach(() => {
37 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
34 closeTab.mockClear();38 closeTab.mockClear();
35 createTab.mockClear();39 createTab.mockClear();
36 stopBrowserRecording.mockClear();40 stopBrowserRecording.mockClear();
...@@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => {...@@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => {
40 afterEach(() => {44 afterEach(() => {
41 vi.useRealTimers();45 vi.useRealTimers();
42 vi.unstubAllGlobals();46 vi.unstubAllGlobals();
47 vi.restoreAllMocks();
48 });
49
50 it("does not create a desktop tab after a failed settings read and permits a later retry", async () => {
51 vi.useFakeTimers();
52 const failure = new Error("Settings read failed");
53 vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure);
54 const failed = acquireDesktopTab("tab_settings_retry");
55
56 await expect(failed.ready).rejects.toBe(failure);
57 expect(createTab).not.toHaveBeenCalled();
58 failed.release();
59 await vi.advanceTimersByTimeAsync(0);
60
61 __setClientSettingsForTests({
62 ...DEFAULT_CLIENT_SETTINGS,
63 browserDefaultZoomFactor: 1.25,
64 browserDefaultAppearance: "dark",
65 });
66 createTab.mockResolvedValueOnce(undefined);
67 const retry = acquireDesktopTab("tab_settings_retry");
68 await retry.ready;
69
70 expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", {
71 zoomFactor: 1.25,
72 colorScheme: "dark",
73 });
74 retry.release();
75 await vi.advanceTimersByTimeAsync(0);
43 });76 });
4477
45 it("shares tab creation readiness across concurrent leases", async () => {78 it("shares tab creation readiness across concurrent leases", async () => {
apps/web/src/browser/openFileInPreview.ts+21-3
...@@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError(...@@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError(
38 readonly message: string;38 readonly message: string;
39}> {}39}> {}
4040
41export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{
42 readonly cause: unknown;
43}> {
44 override get message(): string {
45 return "Saved browser settings could not be loaded.";
46 }
47}
48
41export type OpenPreviewMutation<E = unknown> = (input: {49export type OpenPreviewMutation<E = unknown> = (input: {
42 readonly environmentId: EnvironmentId;50 readonly environmentId: EnvironmentId;
43 readonly input: PreviewOpenInput;51 readonly input: PreviewOpenInput;
...@@ -47,8 +55,13 @@ export async function openUrlInPreview<E>(input: {...@@ -47,8 +55,13 @@ export async function openUrlInPreview<E>(input: {
47 readonly threadRef: ScopedThreadRef;55 readonly threadRef: ScopedThreadRef;
48 readonly url: string;56 readonly url: string;
49 readonly openPreview: OpenPreviewMutation<E>;57 readonly openPreview: OpenPreviewMutation<E>;
50}): Promise<AtomCommandResult<void, E>> {58}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
51 const defaults = await resolveBrowserDefaults();59 const defaults = await resolveBrowserDefaults().catch(
60 (cause: unknown) => new BrowserSettingsReadError({ cause }),
61 );
62 if (defaults instanceof BrowserSettingsReadError) {
63 return AsyncResult.failure(Cause.fail(defaults));
64 }
52 const result = await input.openPreview({65 const result = await input.openPreview({
53 environmentId: input.threadRef.environmentId,66 environmentId: input.threadRef.environmentId,
54 input: {67 input: {
...@@ -82,7 +95,12 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {...@@ -82,7 +95,12 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
82 readonly input: { readonly resource: AssetResource };95 readonly input: { readonly resource: AssetResource };
83 }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;96 }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;
84 readonly openPreview: OpenPreviewMutation<PreviewError>;97 readonly openPreview: OpenPreviewMutation<PreviewError>;
85}): Promise<AtomCommandResult<void, AssetError | PreviewError | BrowserPreviewUnavailableError>> {98}): Promise<
99 AtomCommandResult<
100 void,
101 AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
102 >
103> {
86 if (!isPreviewSupportedInRuntime()) {104 if (!isPreviewSupportedInRuntime()) {
87 return AsyncResult.failure(105 return AsyncResult.failure(
88 Cause.fail(106 Cause.fail(
apps/web/src/browser/useOpenLink.ts+9-4
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
3import { useCallback } from "react";6import { useCallback } from "react";
47
5import { recordVisitForThread } from "~/browserHistoryStore";8import { recordVisitForThread } from "~/browserHistoryStore";
...@@ -12,7 +15,7 @@ import {...@@ -12,7 +15,7 @@ import {
12 resolveBrowserLinkTargetPreference,15 resolveBrowserLinkTargetPreference,
13 resolveLinkTarget,16 resolveLinkTarget,
14} from "./browserLinkTarget";17} from "./browserLinkTarget";
15import { openUrlInPreview } from "./openFileInPreview";18import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview";
1619
17const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;20const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
1821
...@@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;...@@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
24 *27 *
25 * An in-app open that fails falls back to the system browser rather than28 * An in-app open that fails falls back to the system browser rather than
26 * dropping the click: the user asked for the link, and the setting only says29 * dropping the click: the user asked for the link, and the setting only says
27 * where it should go first. The returned promise rejects only when that30 * where it should go first. Failed settings reads reject without opening a
28 * fallback fails too, the same way `shell.openExternal` does.31 * browser. The promise also rejects if the system-browser fallback fails.
29 */32 */
30export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (33export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
31 url: string,34 url: string,
...@@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (...@@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
52 recordVisitForThread(targetThreadRef, url);55 recordVisitForThread(targetThreadRef, url);
53 return;56 return;
54 }57 }
58 const failure = squashAtomCommandFailure(result);
59 if (failure instanceof BrowserSettingsReadError) throw failure;
55 console.error(result.cause);60 console.error(result.cause);
56 }61 }
57 const api = readLocalApi();62 const api = readLocalApi();
apps/web/src/clientPersistenceStorage.test.ts+31-8
...@@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => {...@@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => {
52 expect(readBrowserClientSettings()).toEqual(settings);52 expect(readBrowserClientSettings()).toEqual(settings);
53 });53 });
5454
55 it("reports structured decode failures while preserving the fallback", async () => {55 it.each(["not-json", '{"wordWrap":"invalid"}'])(
56 "does not treat invalid saved settings as absent: %s",
57 async (value) => {
58 const testWindow = getTestWindow();
59 testWindow.localStorage.setItem("t3code:client-settings:v1", value);
60 const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
61
62 expect(() => readBrowserClientSettings()).toThrow(
63 expect.objectContaining({
64 _tag: "LocalStorageOperationError",
65 operation: "decode",
66 storageKey: "t3code:client-settings:v1",
67 }),
68 );
69 expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value);
70 },
71 );
72
73 it("preserves saved settings across a transient read failure", async () => {
56 const testWindow = getTestWindow();74 const testWindow = getTestWindow();
57 testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json");75 const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const };
58 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);76 testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings));
77 const write = vi.spyOn(testWindow.localStorage, "setItem");
78 const failure = new Error("storage unavailable");
79 vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => {
80 throw failure;
81 });
59 const { readBrowserClientSettings } = await import("./clientPersistenceStorage");82 const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
6083
61 expect(readBrowserClientSettings()).toBeNull();84 expect(() => readBrowserClientSettings()).toThrow(
62 expect(consoleError).toHaveBeenCalledWith(
63 "Could not read persisted client settings.",
64 expect.objectContaining({85 expect.objectContaining({
65 _tag: "LocalStorageOperationError",86 _tag: "LocalStorageOperationError",
66 operation: "decode",87 operation: "read",
67 storageKey: "t3code:client-settings:v1",88 storageKey: "t3code:client-settings:v1",
68 cause: expect.anything(),89 cause: failure,
69 }),90 }),
70 );91 );
92 expect(readBrowserClientSettings()).toEqual(settings);
93 expect(write).not.toHaveBeenCalled();
71 });94 });
7295
73 it("defaults word wrap on and discards obsolete wrapping preferences", async () => {96 it("defaults word wrap on and discards obsolete wrapping preferences", async () => {
apps/web/src/clientPersistenceStorage.ts+1-6
...@@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null {...@@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null {
13 return null;13 return null;
14 }14 }
1515
16 try {16 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
17 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
18 } catch (error) {
19 console.error("Could not read persisted client settings.", error);
20 return null;
21 }
22}17}
2318
24export function writeBrowserClientSettings(settings: ClientSettings): void {19export function writeBrowserClientSettings(settings: ClientSettings): void {
apps/web/src/components/ChatMarkdown.tsx+15-2
...@@ -176,6 +176,7 @@ import {...@@ -176,6 +176,7 @@ import {
176 openFileInPreview,176 openFileInPreview,
177 openUrlInPreview,177 openUrlInPreview,
178 BrowserPreviewUnavailableError,178 BrowserPreviewUnavailableError,
179 BrowserSettingsReadError,
179} from "../browser/openFileInPreview";180} from "../browser/openFileInPreview";
180import { resolveLinkTarget } from "../browser/browserLinkTarget";181import { resolveLinkTarget } from "../browser/browserLinkTarget";
181import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";182import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
...@@ -2357,6 +2358,18 @@ function useChatMarkdownState({...@@ -2357,6 +2358,18 @@ function useChatMarkdownState({
2357 }2358 }
2358 return openUrlInPreview({ threadRef, url, openPreview }).then((result) => {2359 return openUrlInPreview({ threadRef, url, openPreview }).then((result) => {
2359 if (result._tag === "Success") recordVisitForThread(threadRef, url);2360 if (result._tag === "Success") recordVisitForThread(threadRef, url);
2361 else if (!isAtomCommandInterrupted(result)) {
2362 const error = squashAtomCommandFailure(result);
2363 if (error instanceof BrowserSettingsReadError) {
2364 toastManager.add(
2365 stackedThreadToast({
2366 type: "error",
2367 title: "Unable to open link in browser",
2368 description: error.message,
2369 }),
2370 );
2371 }
2372 }
2360 return result;2373 return result;
2361 });2374 });
2362 },2375 },
...@@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
2789 }2802 }
2790 event.preventDefault();2803 event.preventDefault();
2791 event.stopPropagation();2804 event.stopPropagation();
2792 // The click was taken from the shell, so an in-app open that fails2805 // Keep the link here if saved settings could not be read.
2793 // hands the link to the system browser instead of dropping it.
2794 void openExternalLinkInPreview(href).then((result) => {2806 void openExternalLinkInPreview(href).then((result) => {
2795 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;2807 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
2796 reportMarkdownActionFailure(2808 reportMarkdownActionFailure(
2797 { operation: "open-link-in-preview", target: href },2809 { operation: "open-link-in-preview", target: href },
2798 result.cause,2810 result.cause,
2799 );2811 );
2812 if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return;
2800 void readLocalApi()?.shell.openExternal(href);2813 void readLocalApi()?.shell.openExternal(href);
2801 });2814 });
2802 }}2815 }}
apps/web/src/components/ChatView.tsx+13
...@@ -162,6 +162,7 @@ import {...@@ -162,6 +162,7 @@ import {
162 useThreadPreviewState,162 useThreadPreviewState,
163} from "../previewStateStore";163} from "../previewStateStore";
164import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";164import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
165import { BrowserSettingsReadError } from "../browser/openFileInPreview";
165import { addBrowserSurface } from "./preview/addBrowserSurface";166import { addBrowserSurface } from "./preview/addBrowserSurface";
166import { closePreviewSession } from "./preview/closePreviewSession";167import { closePreviewSession } from "./preview/closePreviewSession";
167import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";168import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
...@@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) {
3718 threadRef: activeThreadRef,3719 threadRef: activeThreadRef,
3719 openPreview,3720 openPreview,
3720 ...(profileId === undefined ? {} : { profileId }),3721 ...(profileId === undefined ? {} : { profileId }),
3722 }).then((result) => {
3723 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
3724 const error = squashAtomCommandFailure(result);
3725 if (error instanceof BrowserSettingsReadError) {
3726 toastManager.add(
3727 stackedThreadToast({
3728 type: "error",
3729 title: "Unable to open browser",
3730 description: error.message,
3731 }),
3732 );
3733 }
3721 });3734 });
3722 },3735 },
3723 [activeThreadRef, openPreview],3736 [activeThreadRef, openPreview],
apps/web/src/components/ThreadTerminalDrawer.test.ts+88-1
...@@ -1,11 +1,98 @@...@@ -1,11 +1,98 @@
1import { describe, expect, it } from "vite-plus/test";1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 shouldClearTerminalSelectionAction,4 shouldClearTerminalSelectionAction,
5 shouldHandleTerminalExit,5 shouldHandleTerminalExit,
6 terminalContextMenuItems,
6 terminalSelectionLineRange,7 terminalSelectionLineRange,
8 terminalSelectionMenuItems,
9 terminalThemeFromApp,
7} from "./ThreadTerminalDrawer";10} from "./ThreadTerminalDrawer";
811
12describe("terminal selection menus", () => {
13 it("omits Add to chat when the terminal has no chat target", () => {
14 expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]);
15 expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([
16 "add-to-chat",
17 "copy",
18 "paste",
19 ]);
20
21 expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([
22 "copy",
23 ]);
24 expect(
25 terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id),
26 ).toEqual(["copy", "paste"]);
27 });
28});
29
30describe("terminalThemeFromApp", () => {
31 afterEach(() => {
32 vi.unstubAllGlobals();
33 });
34
35 it("uses terminal colors inherited by the mount instead of a light document theme", () => {
36 const root = { classList: { contains: () => false } };
37 const body = {};
38 const drawer = {};
39 let canvasColor = "#000";
40 const colors: Record<string, [number, number, number, number]> = {
41 "#000": [0, 0, 0, 255],
42 "#fff": [255, 255, 255, 255],
43 "#ddd": [221, 221, 221, 255],
44 "#111": [17, 17, 17, 255],
45 };
46
47 vi.stubGlobal("document", {
48 documentElement: root,
49 body,
50 querySelector: () => drawer,
51 createElement: () => ({
52 width: 0,
53 height: 0,
54 getContext: () => ({
55 clearRect: () => undefined,
56 fillRect: () => undefined,
57 get fillStyle() {
58 return canvasColor;
59 },
60 set fillStyle(value: string) {
61 canvasColor = value;
62 },
63 getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }),
64 }),
65 }),
66 });
67 vi.stubGlobal("getComputedStyle", (element: object) => {
68 const local = element === drawer;
69 const values = local
70 ? {
71 "--terminal-background": "#000",
72 "--terminal-foreground": "#fff",
73 "--terminal-cursor": "#ddd",
74 "--terminal-selection-background": "rgba(255, 255, 255, 0.2)",
75 }
76 : {
77 "--terminal-background": "#fff",
78 "--terminal-foreground": "#111",
79 };
80 return {
81 backgroundColor: local ? "#000" : "#fff",
82 color: local ? "#fff" : "#111",
83 colorScheme: local ? "dark" : "light",
84 getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "",
85 };
86 });
87
88 const theme = terminalThemeFromApp();
89
90 expect(theme.background).toEqual({ r: 0, g: 0, b: 0 });
91 expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 });
92 expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 });
93 });
94});
95
9describe("terminal selection actions", () => {96describe("terminal selection actions", () => {
10 it("clears a pending or currently owned menu when the selection disappears", () => {97 it("clears a pending or currently owned menu when the selection disappears", () => {
11 expect(98 expect(
apps/web/src/components/ThreadTerminalDrawer.tsx+60-17
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20} from "lucide-react";20} from "lucide-react";
21import {21import {
22 type ContextMenuItem,22 type ContextMenuItem,
23 type ProviderInstanceId,
23 type ResolvedKeybindingsConfig,24 type ResolvedKeybindingsConfig,
24 type ScopedThreadRef,25 type ScopedThreadRef,
25 type ThreadId,26 type ThreadId,
...@@ -41,6 +42,7 @@ import {...@@ -41,6 +42,7 @@ import {
41import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";42import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
42import { Button } from "~/components/ui/button";43import { Button } from "~/components/ui/button";
43import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";44import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
45import { stackedThreadToast, toastManager } from "~/components/ui/toast";
44import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";46import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
45import { cn } from "~/lib/utils";47import { cn } from "~/lib/utils";
46import { type TerminalContextSelection } from "~/lib/terminalContext";48import { type TerminalContextSelection } from "~/lib/terminalContext";
...@@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s...@@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s
172}174}
173175
174export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {176export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {
175 const isDark = document.documentElement.classList.contains("dark");
176 const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
177 const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
178 const drawerSurface =177 const drawerSurface =
179 mountElement?.closest(".thread-terminal-drawer") ??178 mountElement?.closest(".thread-terminal-drawer") ??
180 document.querySelector(".thread-terminal-drawer") ??179 document.querySelector(".thread-terminal-drawer") ??
181 document.body;180 document.body;
182 const drawerStyles = getComputedStyle(drawerSurface);181 const drawerStyles = getComputedStyle(drawerSurface);
182 const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles;
183 const colorScheme = themeStyles.colorScheme;
184 const isDark =
185 colorScheme === "dark"
186 ? true
187 : colorScheme === "light"
188 ? false
189 : document.documentElement.classList.contains("dark");
190 const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
191 const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
183 const bodyStyles = getComputedStyle(document.body);192 const bodyStyles = getComputedStyle(document.body);
184 const themeStyles = getComputedStyle(document.documentElement);193 const rootThemeStyles = getComputedStyle(document.documentElement);
185 const background = normalizeComputedColor(194 const background = normalizeComputedColor(
186 drawerStyles.backgroundColor,195 drawerStyles.backgroundColor,
187 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),196 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),
...@@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty...@@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
190 drawerStyles.color,199 drawerStyles.color,
191 normalizeComputedColor(bodyStyles.color, fallbackForeground),200 normalizeComputedColor(bodyStyles.color, fallbackForeground),
192 );201 );
193 const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background);202 const terminalBackground = readThemeColor(
194 const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground);203 themeStyles,
204 "--terminal-background",
205 readThemeColor(rootThemeStyles, "--terminal-background", background),
206 );
207 const terminalForeground = readThemeColor(
208 themeStyles,
209 "--terminal-foreground",
210 readThemeColor(rootThemeStyles, "--terminal-foreground", foreground),
211 );
195 const terminalCursor = readThemeColor(212 const terminalCursor = readThemeColor(
196 themeStyles,213 themeStyles,
197 "--terminal-cursor",214 "--terminal-cursor",
...@@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: {...@@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: {
232249
233export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";250export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";
234251
235/** Post-selection popup: just the two selection actions, always enabled. */252/** Post-selection popup: available selection actions, always enabled. */
236export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] {253export function terminalSelectionMenuItems(options?: {
254 canAddToChat?: boolean;
255}): ContextMenuItem<"add-to-chat" | "copy">[] {
237 return [256 return [
238 { id: "add-to-chat", label: "Add to chat" },257 ...(options?.canAddToChat === false
258 ? []
259 : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])),
239 { id: "copy", label: "Copy" },260 { id: "copy", label: "Copy" },
240 ];261 ];
241}262}
...@@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c...@@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c
248 */269 */
249export function terminalContextMenuItems(options: {270export function terminalContextMenuItems(options: {
250 hasSelection: boolean;271 hasSelection: boolean;
272 canAddToChat?: boolean;
251}): ContextMenuItem<TerminalContextMenuAction>[] {273}): ContextMenuItem<TerminalContextMenuAction>[] {
274 const { hasSelection, canAddToChat = true } = options;
252 return [275 return [
253 ...terminalSelectionMenuItems().map((item) => ({276 ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({
254 ...item,277 ...item,
255 disabled: !options.hasSelection,278 disabled: !hasSelection,
256 })),279 })),
257 { id: "paste", label: "Paste" },280 { id: "paste", label: "Paste" },
258 ];281 ];
...@@ -292,8 +315,9 @@ interface TerminalViewportProps {...@@ -292,8 +315,9 @@ interface TerminalViewportProps {
292 cwd: string;315 cwd: string;
293 worktreePath?: string | null;316 worktreePath?: string | null;
294 runtimeEnv?: Record<string, string>;317 runtimeEnv?: Record<string, string>;
318 providerInstanceId?: ProviderInstanceId;
295 onSessionExited: () => void;319 onSessionExited: () => void;
296 onAddTerminalContext: (selection: TerminalContextSelection) => void;320 onAddTerminalContext?: (selection: TerminalContextSelection) => void;
297 focusRequestId: number;321 focusRequestId: number;
298 autoFocus: boolean;322 autoFocus: boolean;
299 visible: boolean;323 visible: boolean;
...@@ -317,6 +341,7 @@ export function TerminalViewport({...@@ -317,6 +341,7 @@ export function TerminalViewport({
317 cwd,341 cwd,
318 worktreePath,342 worktreePath,
319 runtimeEnv,343 runtimeEnv,
344 providerInstanceId,
320 onSessionExited,345 onSessionExited,
321 onAddTerminalContext,346 onAddTerminalContext,
322 focusRequestId,347 focusRequestId,
...@@ -357,8 +382,9 @@ export function TerminalViewport({...@@ -357,8 +382,9 @@ export function TerminalViewport({
357 onSessionExited();382 onSessionExited();
358 });383 });
359 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {384 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
360 onAddTerminalContext(selection);385 onAddTerminalContext?.(selection);
361 });386 });
387 const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined);
362 const readTerminalLabel = useEffectEvent(() => terminalLabel);388 const readTerminalLabel = useEffectEvent(() => terminalLabel);
363 const terminalFontFamily = useClientSettings((settings) =>389 const terminalFontFamily = useClientSettings((settings) =>
364 resolveTerminalFontPreference({390 resolveTerminalFontPreference({
...@@ -383,6 +409,7 @@ export function TerminalViewport({...@@ -383,6 +409,7 @@ export function TerminalViewport({
383 cwd,409 cwd,
384 ...(worktreePath !== undefined ? { worktreePath } : {}),410 ...(worktreePath !== undefined ? { worktreePath } : {}),
385 ...(runtimeEnv ? { env: runtimeEnv } : {}),411 ...(runtimeEnv ? { env: runtimeEnv } : {}),
412 ...(providerInstanceId ? { providerInstanceId } : {}),
386 },413 },
387 });414 });
388 const writeTerminal = useEffectEvent((data: string) =>415 const writeTerminal = useEffectEvent((data: string) =>
...@@ -635,7 +662,10 @@ export function TerminalViewport({...@@ -635,7 +662,10 @@ export function TerminalViewport({
635 let clicked: TerminalContextMenuAction | null;662 let clicked: TerminalContextMenuAction | null;
636 try {663 try {
637 clicked = await localApi.contextMenu.show(664 clicked = await localApi.contextMenu.show(
638 terminalContextMenuItems({ hasSelection: selectionAction !== null }),665 terminalContextMenuItems({
666 hasSelection: selectionAction !== null,
667 canAddToChat: canAddSelectionToChat(),
668 }),
639 { x: event.clientX, y: event.clientY },669 { x: event.clientX, y: event.clientY },
640 );670 );
641 } catch (error) {671 } catch (error) {
...@@ -648,7 +678,9 @@ export function TerminalViewport({...@@ -648,7 +678,9 @@ export function TerminalViewport({
648 }678 }
649 switch (clicked) {679 switch (clicked) {
650 case "add-to-chat":680 case "add-to-chat":
651 if (selectionAction) addSelectionToChat(selectionAction.selection);681 if (selectionAction && canAddSelectionToChat()) {
682 addSelectionToChat(selectionAction.selection);
683 }
652 return;684 return;
653 case "copy":685 case "copy":
654 if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);686 if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);
...@@ -675,7 +707,10 @@ export function TerminalViewport({...@@ -675,7 +707,10 @@ export function TerminalViewport({
675 const requestId = ++selectionActionRequestIdRef.current;707 const requestId = ++selectionActionRequestIdRef.current;
676 openSelectionMenuRequestIdRef.current = requestId;708 openSelectionMenuRequestIdRef.current = requestId;
677 const clicked = await localApi.contextMenu709 const clicked = await localApi.contextMenu
678 .show(terminalSelectionMenuItems(), nextAction.position)710 .show(
711 terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }),
712 nextAction.position,
713 )
679 .finally(() => {714 .finally(() => {
680 if (openSelectionMenuRequestIdRef.current === requestId) {715 if (openSelectionMenuRequestIdRef.current === requestId) {
681 openSelectionMenuRequestIdRef.current = null;716 openSelectionMenuRequestIdRef.current = null;
...@@ -686,7 +721,7 @@ export function TerminalViewport({...@@ -686,7 +721,7 @@ export function TerminalViewport({
686 }721 }
687 switch (clicked) {722 switch (clicked) {
688 case "add-to-chat":723 case "add-to-chat":
689 addSelectionToChat(nextAction.selection);724 if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection);
690 return;725 return;
691 case "copy":726 case "copy":
692 await copySelection(nextAction.clipboardText, requestId);727 await copySelection(nextAction.clipboardText, requestId);
...@@ -768,6 +803,14 @@ export function TerminalViewport({...@@ -768,6 +803,14 @@ export function TerminalViewport({
768 threadRef,803 threadRef,
769 openPreview,804 openPreview,
770 fallbackToBrowser,805 fallbackToBrowser,
806 }).catch((error: unknown) => {
807 toastManager.add(
808 stackedThreadToast({
809 type: "error",
810 title: "Unable to open link",
811 description: error instanceof Error ? error.message : "An error occurred.",
812 }),
813 );
771 });814 });
772 return;815 return;
773 }816 }
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx created+214
...@@ -0,0 +1,214 @@
1import type { Discovery } from "@t3tools/client-runtime/relay";
2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import { EnvironmentId } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { AsyncResult } from "effect/unstable/reactivity";
6import { act, type ButtonHTMLAttributes } from "react";
7import { create, type ReactTestRenderer } from "react-test-renderer";
8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"];
11
12const discovery = vi.hoisted(() => ({
13 state: null as Discovery.RelayEnvironmentDiscoveryState | null,
14 listeners: new Set<() => void>(),
15 refreshCommand: Symbol("refresh"),
16 registerCommand: Symbol("register"),
17 refresh: vi.fn<() => Promise<AtomCommandResult<void, never>>>(),
18 register: vi.fn(),
19 listEnvironments: vi.fn<() => Promise<DiscoveredEnvironments>>(),
20}));
21
22vi.mock("~/state/relay", () => ({
23 relayEnvironmentDiscovery: { refresh: discovery.refreshCommand },
24}));
25vi.mock("~/connection/catalog", () => ({
26 environmentCatalog: { register: discovery.registerCommand },
27}));
28vi.mock("~/state/use-atom-command", () => ({
29 useAtomCommand: (command: unknown) =>
30 command === discovery.refreshCommand ? discovery.refresh : discovery.register,
31}));
32vi.mock("~/state/environments", async () => {
33 const { useSyncExternalStore } = await import("react");
34 const subscribe = (listener: () => void) => {
35 discovery.listeners.add(listener);
36 return () => discovery.listeners.delete(listener);
37 };
38 const read = () => {
39 if (discovery.state === null) throw new Error("Discovery fixture is not initialized");
40 return discovery.state;
41 };
42 return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) };
43});
44vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null }));
45vi.mock("../ui/button", () => ({
46 Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
47 <button {...props}>{children}</button>
48 ),
49}));
50vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
51
52import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
53
54const newMachineId = EnvironmentId.make("new-computer");
55const linkedMachines: DiscoveredEnvironments = new Map([
56 [
57 newMachineId,
58 {
59 environment: {
60 environmentId: newMachineId,
61 label: "Work laptop",
62 endpoint: {
63 httpBaseUrl: "https://relay.example.test",
64 wsBaseUrl: "wss://relay.example.test/ws",
65 providerKind: "manual",
66 },
67 linkedAt: "2026-09-05T12:00:00.000Z",
68 },
69 availability: "online",
70 status: Option.none(),
71 error: Option.none(),
72 },
73 ],
74]);
75
76let renderer: ReactTestRenderer | null;
77let page: EventTarget & { visibilityState: DocumentVisibilityState };
78let browserWindow: EventTarget;
79
80function publish(state: Discovery.RelayEnvironmentDiscoveryState) {
81 discovery.state = state;
82 for (const listener of discovery.listeners) listener();
83}
84
85async function mount(refreshWhileEmpty = true) {
86 await act(async () => {
87 renderer = create(
88 <CloudEnvironmentConnectRows
89 primaryEnvironmentId={null}
90 savedEnvironments={[]}
91 showSavedEnvironments
92 refreshWhileEmpty={refreshWhileEmpty}
93 empty={<p>Waiting for your computer to connect.</p>}
94 />,
95 );
96 });
97}
98
99async function advance(milliseconds: number) {
100 await act(async () => {
101 await vi.advanceTimersByTimeAsync(milliseconds);
102 });
103}
104
105beforeEach(() => {
106 vi.useFakeTimers();
107 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
108 page = Object.assign(new EventTarget(), { visibilityState: "visible" as const });
109 browserWindow = new EventTarget();
110 vi.stubGlobal("document", page);
111 vi.stubGlobal("window", browserWindow);
112 renderer = null;
113 discovery.listeners.clear();
114 discovery.state = {
115 environments: new Map(),
116 refreshing: false,
117 offline: false,
118 error: Option.none(),
119 };
120 discovery.listEnvironments.mockReset().mockResolvedValue(new Map());
121 discovery.refresh.mockReset().mockImplementation(async () => {
122 publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() });
123 const environments = await discovery.listEnvironments();
124 publish({ environments, refreshing: false, offline: false, error: Option.none() });
125 return AsyncResult.success(undefined);
126 });
127});
128
129afterEach(async () => {
130 await act(async () => renderer?.unmount());
131 vi.useRealTimers();
132 vi.unstubAllGlobals();
133});
134
135describe("cloud onboarding discovery", () => {
136 it("shows a newly linked computer without remounting and stops polling once found", async () => {
137 discovery.listEnvironments
138 .mockResolvedValueOnce(new Map())
139 .mockResolvedValueOnce(linkedMachines);
140 await mount();
141 expect(renderer!.root.findByType("p").children).toEqual([
142 "Waiting for your computer to connect.",
143 ]);
144
145 await advance(5_000);
146
147 expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([
148 "Work laptop",
149 ]);
150 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
151 await advance(30_000);
152 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
153 });
154
155 it("waits while hidden and refreshes immediately when visible again", async () => {
156 page.visibilityState = "hidden";
157 await mount();
158 await advance(30_000);
159 expect(discovery.listEnvironments).not.toHaveBeenCalled();
160
161 await act(async () => {
162 page.visibilityState = "visible";
163 page.dispatchEvent(new Event("visibilitychange"));
164 });
165 expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
166
167 page.visibilityState = "hidden";
168 page.dispatchEvent(new Event("visibilitychange"));
169 browserWindow.dispatchEvent(new Event("focus"));
170 await advance(30_000);
171 expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
172 });
173
174 it("does not overlap a slow refresh or restart polling after unmount", async () => {
175 let resolveRefresh!: (environments: DiscoveredEnvironments) => void;
176 const pending = new Promise<DiscoveredEnvironments>((resolve) => {
177 resolveRefresh = resolve;
178 });
179 discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending);
180 await mount();
181 await advance(5_000);
182 expect(renderer!.root.findByType("p").children).toEqual([
183 "Waiting for your computer to connect.",
184 ]);
185
186 browserWindow.dispatchEvent(new Event("focus"));
187 page.dispatchEvent(new Event("visibilitychange"));
188 await advance(30_000);
189 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
190
191 await act(async () => renderer!.unmount());
192 renderer = null;
193 await act(async () => resolveRefresh(new Map()));
194 await advance(30_000);
195 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
196 });
197
198 it("pauses while offline and resumes when discovery is online", async () => {
199 await mount();
200 await act(async () => publish({ ...discovery.state!, offline: true }));
201 await advance(30_000);
202 expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
203
204 await act(async () => publish({ ...discovery.state!, offline: false }));
205 await advance(5_000);
206 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
207 });
208
209 it("does not add polling to other cloud lists", async () => {
210 await mount(false);
211 await advance(30_000);
212 expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
213 });
214});
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+57-3
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11import type { EnvironmentId } from "@t3tools/contracts";11import type { EnvironmentId } from "@t3tools/contracts";
12import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";12import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import { type ReactNode, useCallback, useEffect, useState } from "react";14import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react";
1515
16import { environmentCatalog } from "~/connection/catalog";16import { environmentCatalog } from "~/connection/catalog";
17import { cn } from "~/lib/utils";17import { cn } from "~/lib/utils";
...@@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton";...@@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton";
25import { toastManager } from "../ui/toast";25import { toastManager } from "../ui/toast";
26import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";26import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
2727
28const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;
29
28export interface SavedCloudEnvironmentConnection {30export interface SavedCloudEnvironmentConnection {
29 readonly environmentId: EnvironmentId;31 readonly environmentId: EnvironmentId;
30 readonly connection: EnvironmentConnectionPresentation;32 readonly connection: EnvironmentConnectionPresentation;
...@@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({...@@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({
55 primaryEnvironmentId,57 primaryEnvironmentId,
56 savedEnvironments,58 savedEnvironments,
57 showSavedEnvironments = false,59 showSavedEnvironments = false,
60 refreshWhileEmpty = false,
58 empty = null,61 empty = null,
59}: {62}: {
60 readonly primaryEnvironmentId: EnvironmentId | null;63 readonly primaryEnvironmentId: EnvironmentId | null;
61 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;64 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;
62 readonly showSavedEnvironments?: boolean;65 readonly showSavedEnvironments?: boolean;
66 readonly refreshWhileEmpty?: boolean;
63 readonly empty?: ReactNode;67 readonly empty?: ReactNode;
64}) {68}) {
65 const environmentsState = useRelayEnvironmentDiscovery();69 const environmentsState = useRelayEnvironmentDiscovery();
...@@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({...@@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({
69 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {73 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
70 reportFailure: false,74 reportFailure: false,
71 });75 });
76 const refreshDiscoveryWhenIdle = useEffectEvent(async () => {
77 if (environmentsState.refreshing || environmentsState.offline) return;
78 await refreshRelayEnvironments();
79 });
72 const connectRelayEnvironment = useCallback(80 const connectRelayEnvironment = useCallback(
73 (environment: RelayClientEnvironmentRecord) =>81 (environment: RelayClientEnvironmentRecord) =>
74 registerEnvironment(82 registerEnvironment(
...@@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({...@@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({
89 );97 );
9098
91 useEffect(() => {99 useEffect(() => {
92 void refreshRelayEnvironments();100 if (!refreshWhileEmpty || document.visibilityState === "visible") {
93 }, [refreshRelayEnvironments]);101 void refreshRelayEnvironments();
102 }
103 }, [refreshRelayEnvironments, refreshWhileEmpty]);
94104
95 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {105 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
96 setConnectingEnvironmentId(environment.environmentId);106 setConnectingEnvironmentId(environment.environmentId);
...@@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({...@@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({
132 environment.environmentId !== primaryEnvironmentId &&142 environment.environmentId !== primaryEnvironmentId &&
133 (showSavedEnvironments || !savedById.has(environment.environmentId)),143 (showSavedEnvironments || !savedById.has(environment.environmentId)),
134 );144 );
145 // Discovery clears its list on refresh, so poll only until a machine appears.
146 const shouldRefreshWhileEmpty =
147 refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline;
148
149 useEffect(() => {
150 if (!shouldRefreshWhileEmpty) return;
151 let timer: ReturnType<typeof setTimeout> | undefined;
152 let disposed = false;
153 let pending = false;
154 const visible = () => document.visibilityState === "visible";
155 const schedule = () => {
156 clearTimeout(timer);
157 if (!disposed && visible()) {
158 timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS);
159 }
160 };
161 const refresh = async () => {
162 if (disposed || pending || !visible()) return;
163 clearTimeout(timer);
164 pending = true;
165 try {
166 await refreshDiscoveryWhenIdle();
167 } finally {
168 pending = false;
169 schedule();
170 }
171 };
172 const onFocus = () => void refresh();
173 const onVisibilityChange = () => {
174 clearTimeout(timer);
175 if (visible()) void refresh();
176 };
177
178 schedule();
179 window.addEventListener("focus", onFocus);
180 document.addEventListener("visibilitychange", onVisibilityChange);
181 return () => {
182 disposed = true;
183 clearTimeout(timer);
184 window.removeEventListener("focus", onFocus);
185 document.removeEventListener("visibilitychange", onVisibilityChange);
186 };
187 }, [shouldRefreshWhileEmpty]);
135188
136 const standalone = showSavedEnvironments || savedEnvironments.length === 0;189 const standalone = showSavedEnvironments || savedEnvironments.length === 0;
137190
138 if (191 if (
192 !refreshWhileEmpty &&
139 standalone &&193 standalone &&
140 visibleEnvironments.length === 0 &&194 visibleEnvironments.length === 0 &&
141 environmentsState.refreshing &&195 environmentsState.refreshing &&
apps/web/src/components/onboarding/FirstRunGate.tsx created+244
...@@ -0,0 +1,244 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useLocation, useNavigate } from "@tanstack/react-router";
3import { Atom } from "effect/unstable/reactivity";
4import { RotateCcwIcon } from "lucide-react";
5import { useEffect, useLayoutEffect, useState } from "react";
6
7import {
8 ensureClientSettingsHydrated,
9 useClientSettings,
10 useClientSettingsHydrationStatus,
11} from "../../hooks/useSettings";
12import { mountOnboardingTheme } from "../../hooks/useTheme";
13import { useCompleteOnboarding } from "../../onboarding/firstRun";
14import {
15 isFirstRunWorkspaceProvenanceAuthoritative,
16 isFreshFirstRunWorkspace,
17 resolveFirstRunDecision,
18 resolveHostedFirstRunDecision,
19 transitionFirstRunGateState,
20 type FirstRunGateState,
21} from "../../onboarding/firstRun.logic";
22import {
23 useAllEnvironmentShellsBootstrapped,
24 useProjects,
25 useThreadShells,
26} from "../../state/entities";
27import { useEnvironments } from "../../state/environments";
28import { environmentProjects } from "../../state/projects";
29import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server";
30import { environmentShell } from "../../state/shell";
31import { environmentThreadShells } from "../../state/threads";
32import { Button } from "../ui/button";
33
34/**
35 * Holds back authenticated and hosted app trees until the first-run decision
36 * is known, so a fresh install never flashes the main screen before the wizard.
37 * Nothing renders while pending — no shell, no EventRouter (whose welcome
38 * payload would otherwise navigate into a thread), no dialogs.
39 *
40 * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as
41 * settings hydrate (the common case, no server round-trip). A `null` flag also
42 * covers installs that predate the field, so it alone is not enough — the gate
43 * waits for environment shells to bootstrap and inspects the workspace.
44 * Hosted mode instead checks its saved environment catalog. A timeout shows
45 * recovery for an unreachable primary server without mounting the app tree.
46 */
47
48const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000;
49
50const primaryShellLiveAtom = Atom.make((get) => {
51 const serverConfig = get(primaryServerConfigAtom);
52 return (
53 serverConfig !== null &&
54 get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live"
55 );
56}).pipe(Atom.withLabel("web-onboarding-primary-shell-live"));
57
58const workspaceEvidenceLiveAtom = Atom.make((get) => {
59 const environmentIds = new Set([
60 ...get(environmentProjects.projectsAtom).map((project) => project.environmentId),
61 ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId),
62 ]);
63
64 for (const environmentId of environmentIds) {
65 if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") {
66 return false;
67 }
68 }
69
70 return true;
71}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live"));
72
73export function FirstRunGate({
74 enabled,
75 hostedStatic,
76 children,
77}: {
78 readonly enabled: boolean;
79 readonly hostedStatic: boolean;
80 readonly children: React.ReactNode;
81}) {
82 const navigate = useNavigate();
83 const pathname = useLocation({ select: (location) => location.pathname });
84 const hydrationStatus = useClientSettingsHydrationStatus();
85 const hydrated = hydrationStatus === "ready";
86 const completeOnboarding = useCompleteOnboarding();
87 const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt);
88 const bootstrapped = useAllEnvironmentShellsBootstrapped();
89 const { environments, isReady: environmentCatalogReady } = useEnvironments();
90 const projects = useProjects();
91 const threads = useThreadShells();
92 const serverConfig = useAtomValue(primaryServerConfigAtom);
93 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
94 const primaryShellLive = useAtomValue(primaryShellLiveAtom);
95 const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom);
96 // Within a session settings stay hydrated, so remounts (e.g. returning from
97 // the wizard) resolve synchronously instead of blanking a frame.
98 const [gateState, setGateState] = useState<FirstRunGateState>(() => ({
99 decision:
100 (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null)
101 ? "app"
102 : "pending",
103 stalled: false,
104 }));
105 const { decision, stalled } = gateState;
106 const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying";
107 const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard";
108
109 useLayoutEffect(() => {
110 if (!ownsOnboardingTheme) return;
111 return mountOnboardingTheme();
112 }, [ownsOnboardingTheme]);
113
114 // A workspace still counts as fresh when its only content is the server's
115 // own cwd auto-bootstrap: web mode creates a project + thread from cwd at
116 // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no
117 // projects at all" would mean `npx t3` users never see the wizard. Any
118 // other project, more than one thread, or state in a non-primary
119 // environment is real user state — the aggregate hooks span every
120 // environment, and a saved remote's project must never read as "the
121 // bootstrap project" just because its root string matches the primary cwd.
122 const serverCwd = serverConfig?.cwd ?? null;
123 const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null;
124 const workspaceFresh = isFreshFirstRunWorkspace({
125 primaryEnvironmentId,
126 serverCwd,
127 bootstrapProjectId: serverWelcome?.bootstrapProjectId,
128 bootstrapThreadId: serverWelcome?.bootstrapThreadId,
129 bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated,
130 bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated,
131 projects,
132 threads,
133 });
134
135 const { decision: nextDecision, persistCompletion } = hostedStatic
136 ? resolveHostedFirstRunDecision({
137 hydrated,
138 completed: onboardingCompletedAt !== null,
139 catalogReady: environmentCatalogReady,
140 environmentCount: environments.length,
141 })
142 : resolveFirstRunDecision({
143 enabled,
144 hydrated,
145 completed: onboardingCompletedAt !== null,
146 bootstrapped,
147 authoritative: primaryShellLive,
148 workspaceAuthoritative: workspaceEvidenceLive,
149 workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({
150 welcomeReceived: serverWelcome !== null,
151 bootstrapStatus: serverWelcome?.bootstrapStatus ?? null,
152 }),
153 catalogReady: environmentCatalogReady,
154 serverConfigAvailable: serverConfig !== null,
155 workspaceFresh,
156 projectCount: projects.length,
157 threadCount: threads.length,
158 });
159
160 useEffect(() => {
161 if (decision === "wizard" || !hydrated) return;
162
163 if (persistCompletion && onboardingCompletedAt === null) {
164 void completeOnboarding().catch(() => undefined);
165 }
166
167 setGateState((state) =>
168 transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }),
169 );
170 }, [
171 completeOnboarding,
172 decision,
173 hydrated,
174 nextDecision,
175 onboardingCompletedAt,
176 persistCompletion,
177 ]);
178
179 // A stalled server read gets a recovery screen, but never mounts the app.
180 // The timer starts after settings hydrate so slow local hydration does not
181 // show a false connection failure.
182 useEffect(() => {
183 if (!enabled || decision !== "pending" || !hydrated) return;
184 const timer = window.setTimeout(
185 () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })),
186 FIRST_RUN_DECISION_TIMEOUT_MS,
187 );
188 return () => window.clearTimeout(timer);
189 }, [decision, enabled, hydrated]);
190
191 useEffect(() => {
192 if (decision === "wizard" && pathname !== "/welcome") {
193 void navigate({ to: "/welcome", replace: true });
194 }
195 }, [decision, navigate, pathname]);
196
197 if (settingsReadFailed) {
198 return <FirstRunRecovery reason="settings" retrying={hydrationStatus === "retrying"} />;
199 }
200 if (decision !== "app") {
201 return stalled ? <FirstRunRecovery reason="connection" /> : null;
202 }
203 return children;
204}
205
206function FirstRunRecovery({
207 reason,
208 retrying = false,
209}: {
210 readonly reason: "settings" | "connection";
211 readonly retrying?: boolean;
212}) {
213 const settingsReadFailed = reason === "settings";
214 return (
215 <main className="flex h-dvh min-h-0 items-center justify-center bg-background px-6 text-foreground">
216 <div className="flex max-w-sm flex-col items-center text-center">
217 <h1 className="text-lg font-semibold">
218 {settingsReadFailed ? "Could not read settings" : "Still connecting"}
219 </h1>
220 <p className="mt-2 text-sm text-muted-foreground">
221 {settingsReadFailed
222 ? "Your saved settings could not be loaded."
223 : "T3 Code could not confirm this workspace."}
224 </p>
225 <Button
226 className="mt-5"
227 size="sm"
228 variant="outline"
229 disabled={retrying}
230 onClick={() => {
231 if (settingsReadFailed) {
232 void ensureClientSettingsHydrated().catch(() => undefined);
233 } else {
234 window.location.reload();
235 }
236 }}
237 >
238 <RotateCcwIcon />
239 {settingsReadFailed ? "Retry" : "Reload"}
240 </Button>
241 </div>
242 </main>
243 );
244}
apps/web/src/components/onboarding/WelcomeWizard.tsx created+1478
...@@ -0,0 +1,1478 @@
1import { useAuth } from "@clerk/react";
2import { useAtomValue } from "@effect/atom-react";
3import type {
4 AgentSessionProjectCandidate,
5 EnvironmentId,
6 ProjectId,
7 ScopedProjectRef,
8 ServerConfig,
9 ServerProvider,
10} from "@t3tools/contracts";
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
12import {
13 isAtomCommandInterrupted,
14 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";
16import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts";
17import * as Schema from "effect/Schema";
18import {
19 ArrowRightIcon,
20 CheckIcon,
21 ChevronLeftIcon,
22 ChevronRightIcon,
23 CloudIcon,
24 CopyIcon,
25 LinkIcon,
26 MonitorIcon,
27 TerminalIcon,
28 type LucideIcon,
29} from "lucide-react";
30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
31
32import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
33import { useLocalStorage } from "../../hooks/useLocalStorage";
34import { mountOnboardingTheme } from "../../hooks/useTheme";
35import { hasCloudPublicConfig } from "../../cloud/publicConfig";
36import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
37import { useCompleteOnboarding } from "../../onboarding/firstRun";
38import {
39 partitionOnboardingProjects,
40 resolveOnboardingLandingProject,
41 resolveOnboardingProjectId,
42} from "../../onboarding/projectImport.logic";
43import {
44 getOnboardingProviderState,
45 resolveOnboardingProviderLoginCommand,
46 selectOnboardingProvidersByDriver,
47} from "../../onboarding/providerReadiness.logic";
48import {
49 isOnboardingRelayEnvironment,
50 resolveOnboardingTargetEnvironment,
51} from "../../onboarding/targetEnvironment.logic";
52import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
53import { newProjectId, randomUUID } from "../../lib/utils";
54import { resolveDefaultProviderModelSelection } from "../../providerInstances";
55import { agentSessionImport, agentSessionScan } from "../../state/agentSessions";
56import { readProjects, useProjects } from "../../state/entities";
57import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
58import { useEnvironmentQuery } from "../../state/query";
59import { projectEnvironment } from "../../state/projects";
60import { serverEnvironment } from "../../state/server";
61import { terminalEnvironment } from "../../state/terminal";
62import { useAtomCommand } from "../../state/use-atom-command";
63import { connectPairing } from "../../connection/onboarding";
64import { isElectron } from "../../env";
65import { formatRelativeTimeLabel } from "../../timestampFormat";
66import { getProviderSummary } from "../settings/providerStatus";
67import { getDriverOption } from "../settings/providerDriverMeta";
68import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
69import { TerminalViewport } from "../ThreadTerminalDrawer";
70import { Button } from "../ui/button";
71import { Checkbox } from "../ui/checkbox";
72import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
73import { Input } from "../ui/input";
74import { toastManager } from "../ui/toast";
75import { cn } from "../../lib/utils";
76
77/**
78 * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a
79 * fresh install (no completed-onboarding flag, empty workspace). Flow per the
80 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →
81 * agent setup with inline install terminal → project import → main screen.
82 * Every step past the connection gate is skippable; the whole wizard is
83 * re-runnable by clearing the flag.
84 */
85
86type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import";
87
88type ConnectionMode = "local" | "connect" | "direct";
89
90/**
91 * The machine the agent and import steps run against. Local mode targets the
92 * primary environment; the remote modes prefer the machine the user just
93 * connected (the most recently added connected non-primary environment), so
94 * probing and import happen where their code lives rather than on the local
95 * server that happens to serve the app. Deliberately not a persisted
96 * "primary machine" concept — just whichever machine fits the chosen path
97 * right now, labeled inline on each step.
98 */
99function useOnboardingTargetEnvironment(
100 mode: ConnectionMode,
101 pairedEnvironmentId: EnvironmentId | null,
102) {
103 const { environments } = useEnvironments();
104 const primaryEnvironment = usePrimaryEnvironment();
105 return resolveOnboardingTargetEnvironment({
106 mode,
107 environments,
108 primaryEnvironment,
109 pairedEnvironmentId,
110 });
111}
112
113const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup");
114const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const;
115const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing.";
116
117export function WelcomeWizard({
118 localAvailable,
119 onDone,
120}: {
121 /**
122 * Whether the "Local Only" card is offered. True whenever the app is served
123 * by an authenticated primary server — desktop, `npx t3`, or a dev server —
124 * since that server is "this machine" regardless of the hostname the app
125 * was opened from. Only hosted-static (app.t3.codes) has no local server.
126 */
127 readonly localAvailable: boolean;
128 readonly onDone: (projectRef?: ScopedProjectRef) => void;
129}) {
130 useLayoutEffect(() => mountOnboardingTheme(), []);
131 const completeOnboarding = useCompleteOnboarding();
132 const [step, setStep] = useState<WizardStep>("connection");
133 const [mode, setMode] = useState<ConnectionMode>("local");
134 const [pairedEnvironmentId, setPairedEnvironmentId] = useState<EnvironmentId | null>(null);
135 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);
136 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
137 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
138 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
139 const finish = useCallback(
140 (projectRef?: ScopedProjectRef) => {
141 if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
142 if (completionErrorToastIdRef.current !== null) {
143 toastManager.close(completionErrorToastIdRef.current);
144 completionErrorToastIdRef.current = null;
145 }
146
147 const completion = completeOnboarding()
148 .then(() => {
149 if (completionErrorToastIdRef.current !== null) {
150 toastManager.close(completionErrorToastIdRef.current);
151 completionErrorToastIdRef.current = null;
152 }
153 onDone(projectRef);
154 return true;
155 })
156 .catch(() => {
157 const errorToast = {
158 type: "error",
159 title: "Could not finish setup",
160 description: "Your settings could not be saved. Try again.",
161 } as const;
162 if (completionErrorToastIdRef.current === null) {
163 completionErrorToastIdRef.current = toastManager.add(errorToast);
164 } else {
165 toastManager.update(completionErrorToastIdRef.current, errorToast);
166 }
167 return false;
168 })
169 .finally(() => {
170 if (finishingPromiseRef.current === completion) {
171 finishingPromiseRef.current = null;
172 }
173 });
174 finishingPromiseRef.current = completion;
175 return completion;
176 },
177 [completeOnboarding, onDone],
178 );
179
180 return (
181 <div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-black text-foreground">
182 {isElectron ? (
183 <div
184 aria-hidden
185 className="drag-region h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 wco:pr-[var(--workspace-native-controls-inset)]"
186 />
187 ) : null}
188 <main className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
189 <div className="mx-auto grid min-h-full w-full max-w-5xl content-center gap-10 px-6 py-12 sm:grid-cols-[170px_minmax(0,1fr)] sm:gap-14 sm:px-10 lg:px-12">
190 <aside className="flex min-w-0 flex-col justify-between sm:min-h-72">
191 <nav
192 aria-label="Setup progress"
193 className="flex flex-wrap gap-x-5 gap-y-1 sm:flex-col sm:gap-1"
194 >
195 {ONBOARDING_STAGES.map((stage, index) => (
196 <div
197 key={stage}
198 aria-current={index === stageIndex ? "step" : undefined}
199 className={cn(
200 "flex min-h-9 items-center gap-2.5 text-sm",
201 index < stageIndex
202 ? "text-success-foreground"
203 : index === stageIndex
204 ? "text-foreground"
205 : "text-muted-foreground/60",
206 )}
207 >
208 {index < stageIndex ? (
209 <CheckIcon className="size-3.5" />
210 ) : (
211 <span className="w-3.5 font-mono text-xs">0{index + 1}</span>
212 )}
213 <span>{stage}</span>
214 </div>
215 ))}
216 </nav>
217 {targetEnvironment ? (
218 <div className="mt-8 hidden items-center gap-2 text-xs text-muted-foreground sm:flex">
219 <span className="size-1.5 rounded-full bg-success" />
220 <span className="truncate">{targetEnvironment.label}</span>
221 </div>
222 ) : null}
223 </aside>
224
225 <section className="min-w-0">
226 {step === "connection" ? (
227 <ConnectionStep
228 localAvailable={localAvailable}
229 localLabel={targetEnvironment?.label ?? "This computer"}
230 onLocal={() => {
231 setMode("local");
232 setPairedEnvironmentId(null);
233 setStep("agents");
234 }}
235 onConnect={() => {
236 setMode("connect");
237 setPairedEnvironmentId(null);
238 setStep("connect-machines");
239 }}
240 onDirect={() => {
241 setMode("direct");
242 setPairedEnvironmentId(null);
243 setStep("pair-direct");
244 }}
245 />
246 ) : step === "connect-machines" ? (
247 <ConnectMachinesStep
248 onBack={() => setStep("connection")}
249 onContinue={() => setStep("agents")}
250 />
251 ) : step === "pair-direct" ? (
252 <PairDirectStep
253 onBack={() => setStep("connection")}
254 onPaired={(environmentId) => {
255 setPairedEnvironmentId(environmentId);
256 setStep("agents");
257 }}
258 />
259 ) : step === "agents" ? (
260 <AgentsStep
261 mode={mode}
262 pairedEnvironmentId={pairedEnvironmentId}
263 onBack={() =>
264 setStep(
265 mode === "local"
266 ? "connection"
267 : mode === "connect"
268 ? "connect-machines"
269 : "pair-direct",
270 )
271 }
272 onContinue={() => setStep("import")}
273 onSkip={() => setStep("import")}
274 />
275 ) : (
276 <ImportStep
277 mode={mode}
278 pairedEnvironmentId={pairedEnvironmentId}
279 onBack={() => setStep("agents")}
280 onDone={finish}
281 />
282 )}
283 </section>
284 </div>
285 </main>
286 </div>
287 );
288}
289
290// ── Step 1: connection choice ────────────────────────────────
291
292function ConnectionStep({
293 localAvailable,
294 localLabel,
295 onLocal,
296 onConnect,
297 onDirect,
298}: {
299 readonly localAvailable: boolean;
300 readonly localLabel: string;
301 readonly onLocal: () => void;
302 readonly onConnect: () => void;
303 readonly onDirect: () => void;
304}) {
305 const cloudEnabled = hasCloudPublicConfig();
306 const [choice, setChoice] = useState<"local" | "connect" | "direct">(
307 localAvailable ? "local" : cloudEnabled ? "connect" : "direct",
308 );
309
310 const advance = () => {
311 if (choice === "local") onLocal();
312 else if (choice === "connect") onConnect();
313 else onDirect();
314 };
315
316 return (
317 <>
318 <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">Where is your code?</h1>
319 <p className="mt-2.5 text-sm text-muted-foreground">Choose where your agents will run.</p>
320 <div className="mt-8 border-t border-border">
321 {localAvailable ? (
322 <ConnectionOption
323 icon={MonitorIcon}
324 title="This computer"
325 description={localLabel}
326 truncateDescription
327 detail="No account"
328 selected={choice === "local"}
329 onSelect={() => setChoice("local")}
330 />
331 ) : null}
332 {cloudEnabled ? (
333 <ConnectionOption
334 icon={CloudIcon}
335 title="T3 Connect"
336 description="Your computers, wherever you are"
337 detail="Sign in"
338 selected={choice === "connect"}
339 onSelect={() => setChoice("connect")}
340 />
341 ) : null}
342 <ConnectionOption
343 icon={LinkIcon}
344 title="Pair a server"
345 description="Local network or Tailscale"
346 detail="Pairing link"
347 selected={choice === "direct"}
348 onSelect={() => setChoice("direct")}
349 />
350 </div>
351 <div className="mt-7 flex justify-end">
352 <Button className="gap-2" onClick={advance}>
353 Continue
354 <ArrowRightIcon className="size-3.5" />
355 </Button>
356 </div>
357 </>
358 );
359}
360
361function ConnectionOption({
362 icon: Icon,
363 title,
364 description,
365 truncateDescription = false,
366 detail,
367 selected,
368 onSelect,
369}: {
370 readonly icon: LucideIcon;
371 readonly title: string;
372 readonly description: string;
373 readonly truncateDescription?: boolean;
374 readonly detail: string;
375 readonly selected: boolean;
376 readonly onSelect: () => void;
377}) {
378 return (
379 <button
380 type="button"
381 aria-pressed={selected}
382 onClick={onSelect}
383 className={cn(
384 "group flex min-h-20 w-full cursor-pointer items-center gap-4 border-b border-border px-1 py-3 text-left transition-colors",
385 "outline-none focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-ring",
386 selected
387 ? "text-foreground"
388 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
389 )}
390 >
391 <Icon
392 className={cn("size-[18px]", selected ? "text-success-foreground" : "text-icon-muted")}
393 />
394 <span className="min-w-0 flex-1">
395 <span className="block text-sm font-medium">{title}</span>
396 <span
397 className={cn(
398 "mt-1 block text-xs text-muted-foreground",
399 truncateDescription ? "truncate" : "break-words whitespace-normal",
400 )}
401 >
402 {description}
403 </span>
404 </span>
405 <span className="hidden text-xs text-muted-foreground sm:block">{detail}</span>
406 {selected ? (
407 <CheckIcon className="size-4 text-success-foreground" />
408 ) : (
409 <span className="size-4" />
410 )}
411 </button>
412 );
413}
414
415// ── Step 2: T3 Connect (sign in, then connect machines) ──────
416
417const CONNECT_LOGIN_COMMAND = "npx t3 connect";
418
419/**
420 * Sign-in and machine-connection combined: signed out shows the Clerk prompt,
421 * signed in forks on account state — zero connected machines blocks on the
422 * `npx t3 connect` command and auto-advance is left to the user pressing
423 * Continue once their machine appears; existing machines show a confirmation
424 * list with the command folded away. There is deliberately no "primary
425 * machine" selection.
426 */
427function ConnectMachinesStep({
428 onBack,
429 onContinue,
430}: {
431 readonly onBack: () => void;
432 readonly onContinue: () => void;
433}) {
434 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read
435 // as signed-out mid-transition.
436 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
437 const { openAuthPrompt } = useT3ConnectAuthPrompt();
438 const { environments } = useEnvironments();
439 const primaryEnvironment = usePrimaryEnvironment();
440 const savedEnvironments = environments.filter(isOnboardingRelayEnvironment);
441 // Only a live connection counts: a saved-but-offline machine must not show
442 // the "connected" confirmation (the agents step would find nothing to
443 // probe). Its row still renders in the list either way.
444 const hasRemoteMachines = savedEnvironments.some(
445 (environment) => environment.connection.phase === "connected",
446 );
447
448 if (!isLoaded) {
449 return <StepShell title="Sign in to T3 Connect" onBack={onBack} />;
450 }
451
452 if (!isSignedIn) {
453 return (
454 <StepShell
455 title="Sign in to T3 Connect"
456 description="Connect your computers with one account."
457 onBack={onBack}
458 >
459 <div className="mt-6">
460 <Button onClick={openAuthPrompt}>Sign in</Button>
461 </div>
462 </StepShell>
463 );
464 }
465
466 return (
467 <StepShell
468 title={hasRemoteMachines ? "Your computers" : "Connect your computer"}
469 description={
470 hasRemoteMachines
471 ? "Connected to your T3 account."
472 : "Run this command on the computer with your code."
473 }
474 onBack={onBack}
475 >
476 {hasRemoteMachines ? (
477 <>
478 <div className="mt-6 overflow-hidden border-y border-border">
479 <CloudEnvironmentConnectRows
480 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
481 savedEnvironments={savedEnvironments}
482 showSavedEnvironments
483 empty={null}
484 />
485 </div>
486 <Collapsible className="mt-4">
487 <CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
488 <ChevronRightIcon className="size-3.5 transition-transform duration-200 group-data-panel-open:rotate-90" />
489 Add another machine
490 </CollapsibleTrigger>
491 <CollapsiblePanel>
492 <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-2" />
493 <p className="mt-2 text-xs text-muted-foreground">
494 Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "}
495 <code className="font-mono">npx t3 serve</code>.
496 </p>
497 </CollapsiblePanel>
498 </Collapsible>
499 <div className="mt-7 flex justify-end">
500 <Button onClick={onContinue}>Continue</Button>
501 </div>
502 </>
503 ) : (
504 <>
505 <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-7" prominent />
506 <p className="mt-2 text-xs text-muted-foreground">
507 Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "}
508 <code className="font-mono">npx t3 serve</code>.
509 </p>
510 <div className="mt-5 overflow-hidden border-y border-border">
511 <CloudEnvironmentConnectRows
512 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
513 savedEnvironments={savedEnvironments}
514 showSavedEnvironments
515 refreshWhileEmpty
516 empty={
517 <p className="px-1 py-4 text-xs text-muted-foreground">
518 Waiting for your computer to connect.
519 </p>
520 }
521 />
522 </div>
523 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
524 <Button variant="ghost-muted" onClick={onContinue}>
525 Skip for now
526 </Button>
527 <div className="flex flex-wrap items-center justify-end gap-3">
528 <span className="hidden text-xs text-muted-foreground sm:block">
529 Waiting for connection
530 </span>
531 <Button disabled>Continue</Button>
532 </div>
533 </div>
534 </>
535 )}
536 </StepShell>
537 );
538}
539
540// ── Step 2′: Direct pairing ──────────────────────────────────
541
542/**
543 * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the
544 * server, paste the URL here. Registers the remote environment in this
545 * browser's catalog (same path the hosted /pair surface uses).
546 */
547function PairDirectStep({
548 onBack,
549 onPaired,
550}: {
551 readonly onBack: () => void;
552 readonly onPaired: (environmentId: EnvironmentId) => void;
553}) {
554 const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false });
555 const [pairingUrl, setPairingUrl] = useState("");
556 const [errorMessage, setErrorMessage] = useState("");
557 const [isPairing, setIsPairing] = useState(false);
558 const mountedRef = useRef(true);
559
560 useEffect(() => {
561 mountedRef.current = true;
562 return () => {
563 mountedRef.current = false;
564 };
565 }, []);
566
567 const submit = async () => {
568 setIsPairing(true);
569 setErrorMessage("");
570 const result = await connectPairingEnvironment({ pairingUrl });
571 if (!mountedRef.current) return;
572 setIsPairing(false);
573 if (result._tag === "Success") {
574 onPaired(result.value);
575 return;
576 }
577 if (isAtomCommandInterrupted(result)) return;
578 const cause = squashAtomCommandFailure(result);
579 setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed.");
580 };
581
582 return (
583 <StepShell
584 title="Pair a server"
585 description="Connect with a one-time pairing link."
586 onBack={onBack}
587 >
588 <div className="mt-7 space-y-5">
589 <div>
590 <p className="text-sm text-muted-foreground">
591 <span className="font-mono text-muted-foreground/70">01</span> Run this on your server
592 </p>
593 <CommandBlock command="npx t3 pair" className="mt-2" />
594 <p className="mt-2 text-xs text-muted-foreground">
595 Start the server with <code className="font-mono">npx t3 serve</code> first. Add{" "}
596 <code className="font-mono">--tailscale</code> to use your tailnet.
597 </p>
598 </div>
599 <div>
600 <label className="block text-sm text-muted-foreground" htmlFor="onboarding-pairing-url">
601 <span className="font-mono text-muted-foreground/70">02</span> Paste the pairing link
602 </label>
603 <Input
604 id="onboarding-pairing-url"
605 className="mt-2"
606 size="lg"
607 autoCapitalize="none"
608 autoComplete="off"
609 autoCorrect="off"
610 spellCheck={false}
611 nativeInput
612 disabled={isPairing}
613 placeholder="https://your-server:5230/pair#token=…"
614 value={pairingUrl}
615 onChange={(event) => setPairingUrl(event.currentTarget.value)}
616 onKeyDown={(event) => {
617 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
618 if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit();
619 }}
620 />
621 </div>
622 {errorMessage ? (
623 <div className="rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive">
624 {errorMessage}
625 </div>
626 ) : null}
627 </div>
628 <div className="mt-7 flex justify-end">
629 <Button
630 disabled={isPairing || pairingUrl.trim().length === 0}
631 onClick={() => void submit()}
632 >
633 {isPairing ? "Pairing..." : "Connect"}
634 </Button>
635 </div>
636 </StepShell>
637 );
638}
639
640// ── Step 3: agents ───────────────────────────────────────────
641
642const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const;
643type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];
644
645const AGENT_INSTALL_COMMANDS: Record<OnboardingAgentDriver, string> = {
646 claudeAgent: "npm install -g @anthropic-ai/claude-code",
647 codex: "npm install -g @openai/codex",
648};
649
650/** Setup values stay fixed while provider probes refresh the surrounding cards. */
651interface AgentTerminalSession {
652 readonly environmentId: EnvironmentId;
653 readonly driver: OnboardingAgentDriver;
654 readonly providerInstanceId: ServerProvider["instanceId"];
655 readonly cwd: string;
656 readonly command: string;
657 readonly keybindings: ServerConfig["keybindings"];
658}
659
660/**
661 * Claude Code and Codex use live probe status. Install opens the built-in terminal inline
662 * with the command pre-typed — the update RPC can't install a binary that
663 * isn't there yet (it infers the package manager from the installed binary's
664 * path), and the terminal also handles the interactive login that follows.
665 */
666function AgentsStep({
667 mode,
668 pairedEnvironmentId,
669 onBack,
670 onContinue,
671 onSkip,
672}: {
673 readonly mode: ConnectionMode;
674 readonly pairedEnvironmentId: EnvironmentId | null;
675 readonly onBack: () => void;
676 readonly onContinue: () => void;
677 readonly onSkip: () => void;
678}) {
679 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
680 if (targetEnvironment === null) {
681 return (
682 <StepShell
683 title="Your agents"
684 description="Waiting for this computer to connect."
685 onBack={onBack}
686 >
687 <div className="mt-6 flex justify-end">
688 <Button variant="ghost-muted" onClick={onSkip}>
689 Skip for now
690 </Button>
691 </div>
692 </StepShell>
693 );
694 }
695 return (
696 <ConnectedAgentsStep
697 key={targetEnvironment.environmentId}
698 environmentId={targetEnvironment.environmentId}
699 machineLabel={targetEnvironment.label}
700 onBack={onBack}
701 onContinue={onContinue}
702 onSkip={onSkip}
703 />
704 );
705}
706
707function ConnectedAgentsStep({
708 environmentId,
709 machineLabel,
710 onBack,
711 onContinue,
712 onSkip,
713}: {
714 readonly environmentId: EnvironmentId;
715 readonly machineLabel: string;
716 readonly onBack: () => void;
717 readonly onContinue: () => void;
718 readonly onSkip: () => void;
719}) {
720 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
721 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
722 reportFailure: false,
723 });
724 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
725 const [terminalSession, setTerminalSession] = useState<AgentTerminalSession | null>(null);
726
727 // Re-probe on entry so freshly installed CLIs show up without a manual
728 // refresh; harmless when nothing changed (single-flighted per environment).
729 useEffect(() => {
730 void refreshProviders({ environmentId, input: {} });
731 }, [environmentId, refreshProviders]);
732
733 const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]);
734
735 const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({
736 driver,
737 provider: byDriver.get(driver),
738 }));
739 const readyCount = primaryAgents.filter(
740 ({ provider }) => getOnboardingProviderState(provider) === "ready",
741 ).length;
742 return (
743 <StepShell title="Your agents" description={`Detected on ${machineLabel}.`} onBack={onBack}>
744 <div className="mt-7 border-t border-border">
745 {primaryAgents.map(({ driver, provider }) => (
746 <AgentCard
747 key={driver}
748 driver={driver}
749 provider={provider}
750 terminalOpen={terminalSession?.driver === driver}
751 terminalAvailable={serverConfig !== null}
752 onOpenTerminal={() => {
753 if (provider === undefined || serverConfig === null) return;
754 setTerminalSession({
755 environmentId,
756 driver,
757 providerInstanceId: provider.instanceId,
758 cwd: serverConfig.cwd,
759 command: provider.installed
760 ? resolveOnboardingProviderLoginCommand(
761 provider,
762 serverConfig.settings,
763 serverConfig.environment.platform.os,
764 )
765 : AGENT_INSTALL_COMMANDS[driver],
766 keybindings: serverConfig.keybindings,
767 });
768 }}
769 />
770 ))}
771 </div>
772 {terminalSession !== null ? (
773 <AgentInstallTerminal
774 key={`${terminalSession.environmentId}:${terminalSession.providerInstanceId}:${terminalSession.driver}`}
775 session={terminalSession}
776 onClose={() => {
777 setTerminalSession(null);
778 void refreshProviders({ environmentId, input: {} });
779 }}
780 />
781 ) : null}
782 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
783 <Button variant="ghost-muted" onClick={onSkip}>
784 Skip
785 </Button>
786 <div className="flex flex-wrap items-center justify-end gap-3">
787 <span className="text-xs text-muted-foreground">
788 {readyCount} of {primaryAgents.length} ready
789 </span>
790 <Button className="gap-2" onClick={onContinue}>
791 Continue
792 <ArrowRightIcon className="size-3.5" />
793 </Button>
794 </div>
795 </div>
796 </StepShell>
797 );
798}
799
800function AgentCard({
801 driver,
802 provider,
803 terminalOpen,
804 terminalAvailable,
805 onOpenTerminal,
806}: {
807 readonly driver: OnboardingAgentDriver;
808 readonly provider: ServerProvider | undefined;
809 readonly terminalOpen: boolean;
810 readonly terminalAvailable: boolean;
811 readonly onOpenTerminal: () => void;
812}) {
813 const meta = getDriverOption(ProviderDriverKind.make(driver));
814 const Icon = meta?.icon;
815 const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver);
816 const summary = getProviderSummary(provider);
817 const providerState = getOnboardingProviderState(provider);
818
819 return (
820 <div className="flex min-h-20 items-center gap-4 border-b border-border py-3">
821 {Icon ? (
822 <Icon className={cn("size-6 shrink-0", driver !== "claudeAgent" && "fill-foreground")} />
823 ) : null}
824 <div className="min-w-0 flex-1">
825 <span className="block text-sm font-medium text-foreground">{displayName}</span>
826 <p className="mt-1 truncate text-xs text-muted-foreground">
827 {summary.headline}
828 {summary.detail ? ` · ${summary.detail}` : ""}
829 </p>
830 </div>
831 <div className="shrink-0">
832 {providerState === "ready" ? (
833 <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
834 <CheckIcon className="size-3.5" />
835 Ready
836 </span>
837 ) : providerState === "checking" ? (
838 <span className="text-xs text-muted-foreground">Checking...</span>
839 ) : providerState === "disabled" ? (
840 <span className="text-xs text-muted-foreground">Disabled</span>
841 ) : providerState === "attention" ? (
842 <span className="text-xs text-muted-foreground">{summary.headline}</span>
843 ) : (
844 <Button
845 size="xs"
846 variant="ghost"
847 onClick={onOpenTerminal}
848 disabled={terminalOpen || !terminalAvailable}
849 >
850 <TerminalIcon className="size-3.5" />
851 {providerState === "signIn" ? "Sign in" : "Install"}
852 </Button>
853 )}
854 </div>
855 </div>
856 );
857}
858
859/**
860 * Inline install terminal. Opens a PTY on the connected environment under a
861 * synthetic onboarding thread id (terminals are keyed by free-form thread id;
862 * the server validates only the cwd) and pre-types the install or login
863 * command without submitting, so the user reviews and presses Enter.
864 */
865function AgentInstallTerminal({
866 session,
867 onClose,
868}: {
869 readonly session: AgentTerminalSession;
870 readonly onClose: () => void;
871}) {
872 const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;
873 // Same terminal typography preference the thread drawer honors.
874 const [advancedTypography] = useLocalStorage(
875 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
876 false,
877 Schema.Boolean,
878 );
879 const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false });
880 const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false });
881 const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
882 const setupQueueRef = useRef(Promise.resolve());
883 const setupGenerationRef = useRef(0);
884 const activeSetupGenerationRef = useRef<number | null>(null);
885 const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`);
886 const threadRef = useMemo(
887 () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID),
888 [environmentId],
889 );
890 const [setupAttempt, setSetupAttempt] = useState(0);
891 const [setupState, setSetupState] = useState<
892 "preparing" | "ready" | "openFailed" | "writeFailed"
893 >("preparing");
894 const terminalReady = setupState === "ready" || setupState === "writeFailed";
895
896 // Keep each setup generation distinct. In Strict Mode, a canceled open can
897 // finish after the replacement setup starts; it must not close or pre-type
898 // into the replacement session that shares this terminal id.
899 useEffect(() => {
900 const generation = setupGenerationRef.current + 1;
901 setupGenerationRef.current = generation;
902 activeSetupGenerationRef.current = generation;
903 setSetupState("preparing");
904
905 setupQueueRef.current = setupQueueRef.current.then(async () => {
906 if (activeSetupGenerationRef.current !== generation) return;
907 const opened = await openTerminal({
908 environmentId,
909 input: {
910 threadId: AGENT_ONBOARDING_THREAD_ID,
911 terminalId,
912 cwd,
913 providerInstanceId,
914 },
915 });
916 if (opened._tag !== "Success") {
917 if (activeSetupGenerationRef.current === generation) setSetupState("openFailed");
918 return;
919 }
920
921 if (activeSetupGenerationRef.current !== generation) return;
922
923 const wrote = await writeTerminal({
924 environmentId,
925 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command },
926 });
927 if (activeSetupGenerationRef.current !== generation) return;
928 setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");
929 });
930
931 // Every exit path unmounts the drawer (Done, Continue/Skip, card switch,
932 // session exit), so this cleanup is the single place the PTY dies —
933 // nothing is left running behind the wizard. An interrupted install is
934 // re-runnable from the card.
935 return () => {
936 if (activeSetupGenerationRef.current === generation) {
937 activeSetupGenerationRef.current = null;
938 }
939 setupQueueRef.current = setupQueueRef.current.then(async () => {
940 await closeTerminal({
941 environmentId,
942 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },
943 });
944 });
945 };
946 }, [
947 closeTerminal,
948 command,
949 cwd,
950 environmentId,
951 openTerminal,
952 providerInstanceId,
953 setupAttempt,
954 terminalId,
955 writeTerminal,
956 ]);
957
958 return (
959 <div className="thread-terminal-drawer mt-4 overflow-hidden rounded-lg border border-border/70 bg-background text-foreground">
960 <div className="flex items-center justify-between border-b border-border/60 bg-background/60 px-3 py-1.5">
961 <span className="text-[11px] font-medium text-muted-foreground">
962 {setupState === "writeFailed" ? (
963 <>
964 Run <code className="rounded bg-muted px-1 font-mono">{command}</code> in this
965 terminal.
966 </>
967 ) : setupState === "ready" ? (
968 "Review the command, then press Enter to run it."
969 ) : setupState === "openFailed" ? (
970 "Could not open the setup terminal."
971 ) : (
972 "Preparing command..."
973 )}
974 </span>
975 <div className="flex items-center gap-1">
976 {setupState === "openFailed" ? (
977 <Button size="xs" variant="ghost" onClick={() => setSetupAttempt((value) => value + 1)}>
978 Retry
979 </Button>
980 ) : null}
981 <Button size="xs" variant="ghost-muted" onClick={onClose}>
982 Close
983 </Button>
984 </div>
985 </div>
986 <div className="h-64">
987 {terminalReady ? (
988 <TerminalViewport
989 threadRef={threadRef}
990 threadId={AGENT_ONBOARDING_THREAD_ID}
991 terminalId={terminalId}
992 terminalLabel={`Install ${driver}`}
993 cwd={cwd}
994 providerInstanceId={providerInstanceId}
995 advancedTypography={advancedTypography}
996 onSessionExited={onClose}
997 focusRequestId={1}
998 autoFocus
999 visible
1000 resizeEpoch={0}
1001 drawerHeight={256}
1002 keybindings={keybindings}
1003 />
1004 ) : null}
1005 </div>
1006 </div>
1007 );
1008}
1009
1010// ── Step 4: import ───────────────────────────────────────────
1011
1012/**
1013 * One-decision import (4B): a summary line with Import recent / Choose /
1014 * Skip. The default imports only projects touched in the last 30 days;
1015 * Choose expands a checklist including older ones. Imported projects also
1016 * receive Codex and Claude threads active within the last 30 days.
1017 */
1018function ImportStep({
1019 mode,
1020 pairedEnvironmentId,
1021 onBack,
1022 onDone,
1023}: {
1024 readonly mode: ConnectionMode;
1025 readonly pairedEnvironmentId: EnvironmentId | null;
1026 readonly onBack: () => void;
1027 readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>;
1028}) {
1029 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
1030 const environmentId = targetEnvironment?.environmentId ?? null;
1031 const machineLabel = targetEnvironment?.label ?? "this machine";
1032 const providers = useAtomValue(
1033 serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)),
1034 );
1035 const scan = useEnvironmentQuery(
1036 environmentId === null ? null : agentSessionScan({ environmentId, input: {} }),
1037 );
1038 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
1039 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
1040 const projects = useProjects();
1041 const [choosing, setChoosing] = useState(false);
1042 const [deselected, setDeselected] = useState<ReadonlySet<string>>(new Set());
1043 const [isImporting, setIsImporting] = useState(false);
1044 const [importError, setImportError] = useState("");
1045 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
1046 // Keep project creation attempts separate from completed history imports so both can retry.
1047 const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
1048 const projectsWithImportedHistoryRef = useRef(new Map<string, ScopedProjectRef>());
1049 const lastImportSelectionRef = useRef<ReadonlyArray<string>>([]);
1050 const projectAttemptsRef = useRef(
1051 new Map<string, { readonly projectId: ProjectId; readonly commandId: CommandId }>(),
1052 );
1053 const importGenerationRef = useRef(0);
1054
1055 // Candidate paths are per-environment; a target switch would otherwise
1056 // leave stale entries in the deselection set (and stale success records).
1057 useEffect(() => {
1058 importGenerationRef.current += 1;
1059 setDeselected(new Set());
1060 setIsImporting(false);
1061 setImportError("");
1062 setLandingProject(null);
1063 importedProjectsRef.current = new Map();
1064 projectsWithImportedHistoryRef.current = new Map();
1065 lastImportSelectionRef.current = [];
1066 projectAttemptsRef.current = new Map();
1067 return () => {
1068 importGenerationRef.current += 1;
1069 };
1070 }, [environmentId]);
1071
1072 useEffect(() => {
1073 if (
1074 landingProject !== null &&
1075 landingProject.environmentId === environmentId &&
1076 projects.some(
1077 (project) =>
1078 project.id === landingProject.projectId &&
1079 project.environmentId === landingProject.environmentId,
1080 )
1081 ) {
1082 setLandingProject(null);
1083 void onDone(landingProject).then((completed) => {
1084 if (!completed) setIsImporting(false);
1085 });
1086 }
1087 }, [environmentId, landingProject, onDone, projects]);
1088
1089 const { available: candidates, recent } = useMemo(
1090 () => partitionOnboardingProjects(scan.data?.candidates ?? []),
1091 [scan.data],
1092 );
1093 const more = candidates.length - recent.length;
1094 const scanTruncated = scan.data?.truncated === true;
1095 const scanLimitNotice = scanTruncated ? (
1096 <p className="mt-3 text-sm text-muted-foreground" role="status">
1097 {SCAN_LIMIT_MESSAGE}
1098 </p>
1099 ) : null;
1100
1101 const finishAfterImport = () => {
1102 const projectRef = resolveOnboardingLandingProject(
1103 lastImportSelectionRef.current,
1104 projectsWithImportedHistoryRef.current,
1105 importedProjectsRef.current,
1106 );
1107 if (projectRef === undefined) {
1108 void onDone();
1109 return;
1110 }
1111 setIsImporting(true);
1112 setLandingProject(projectRef);
1113 };
1114
1115 const runImport = async (selection: ReadonlyArray<AgentSessionProjectCandidate>) => {
1116 if (environmentId === null || selection.length === 0) {
1117 void onDone();
1118 return;
1119 }
1120 setIsImporting(true);
1121 setImportError("");
1122 lastImportSelectionRef.current = selection.map((candidate) => candidate.path);
1123 const importGeneration = importGenerationRef.current;
1124 const importedProjects = importedProjectsRef.current;
1125 const projectAttempts = projectAttemptsRef.current;
1126 const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null);
1127 // Interrupted imports are neither failures nor successes — the command was
1128 // superseded or the environment dropped — but they still didn't land, so
1129 // they must not read as "imported everything". Retries skip paths that
1130 // already landed this session (re-creating them would only trip the
1131 // duplicate-root invariant and read as a failure).
1132 let importedProjectsCount =
1133 importedProjects.size > 0
1134 ? selection.filter((candidate) => importedProjects.has(candidate.path)).length
1135 : 0;
1136 let importedThreadCount = 0;
1137 let skippedThreadCount = 0;
1138 let shouldRefreshScan = false;
1139 for (const candidate of selection) {
1140 if (
1141 importGeneration !== importGenerationRef.current ||
1142 importedProjects !== importedProjectsRef.current
1143 ) {
1144 return;
1145 }
1146 if (importedProjects.has(candidate.path)) continue;
1147 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
1148 if (projectId === null) {
1149 let attempt = projectAttempts.get(candidate.path);
1150 if (attempt === undefined) {
1151 const nextProjectId = newProjectId();
1152 attempt = {
1153 projectId: nextProjectId,
1154 commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),
1155 };
1156 projectAttempts.set(candidate.path, attempt);
1157 }
1158 projectId = attempt.projectId;
1159 const result = await createProject({
1160 environmentId,
1161 input: {
1162 projectId,
1163 commandId: attempt.commandId,
1164 title: candidate.title,
1165 workspaceRoot: candidate.path,
1166 createWorkspaceRootIfMissing: false,
1167 defaultModelSelection,
1168 },
1169 });
1170 if (
1171 importGeneration !== importGenerationRef.current ||
1172 importedProjects !== importedProjectsRef.current
1173 ) {
1174 return;
1175 }
1176 if (result._tag !== "Success") {
1177 if (!isAtomCommandInterrupted(result)) {
1178 projectAttempts.delete(candidate.path);
1179 shouldRefreshScan = true;
1180 }
1181 continue;
1182 }
1183 }
1184
1185 const threadImportResult = await importThreads({
1186 environmentId,
1187 input: { projectId, expectedWorkspaceRoot: candidate.path },
1188 });
1189 if (
1190 importGeneration !== importGenerationRef.current ||
1191 importedProjects !== importedProjectsRef.current
1192 ) {
1193 return;
1194 }
1195 if (threadImportResult._tag === "Success") {
1196 importedThreadCount += threadImportResult.value.importedCount;
1197 skippedThreadCount += threadImportResult.value.skippedCount;
1198 if (threadImportResult.value.importedCount > 0) {
1199 projectsWithImportedHistoryRef.current.set(
1200 candidate.path,
1201 scopeProjectRef(environmentId, projectId),
1202 );
1203 }
1204 if (threadImportResult.value.skippedCount === 0) {
1205 importedProjectsCount += 1;
1206 importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId));
1207 }
1208 } else if (!isAtomCommandInterrupted(threadImportResult)) {
1209 projectAttempts.delete(candidate.path);
1210 shouldRefreshScan = true;
1211 }
1212 }
1213 if (shouldRefreshScan) scan.refresh();
1214 setIsImporting(false);
1215 if (importedProjectsCount < selection.length) {
1216 if (importedThreadCount > 0 && skippedThreadCount > 0) {
1217 setImportError(
1218 `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`,
1219 );
1220 } else if (skippedThreadCount > 0) {
1221 setImportError(
1222 `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`,
1223 );
1224 } else if (importedThreadCount > 0) {
1225 setImportError(
1226 `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`,
1227 );
1228 } else {
1229 setImportError("Could not import thread history.");
1230 }
1231 return;
1232 }
1233 finishAfterImport();
1234 };
1235
1236 if (environmentId === null || (scan.isPending && scan.data === null)) {
1237 return (
1238 <StepShell
1239 title="Your projects"
1240 description="Looking for projects from Claude Code and Codex."
1241 onBack={onBack}
1242 >
1243 <div className="mt-6 flex justify-end">
1244 <Button variant="ghost-muted" onClick={() => void onDone()}>
1245 Skip
1246 </Button>
1247 </div>
1248 </StepShell>
1249 );
1250 }
1251
1252 if (scan.error !== null || candidates.length === 0) {
1253 return (
1254 <StepShell
1255 title="Your projects"
1256 description={
1257 scan.error !== null
1258 ? "Could not check this computer for projects."
1259 : scanTruncated
1260 ? SCAN_LIMIT_MESSAGE
1261 : "No existing Claude Code or Codex projects found."
1262 }
1263 onBack={onBack}
1264 >
1265 {scan.error !== null ? (
1266 <p className="mt-3 text-xs text-muted-foreground">You can add projects later.</p>
1267 ) : null}
1268 <div className="mt-6 flex justify-end gap-2">
1269 {scan.error !== null ? (
1270 <Button variant="ghost" onClick={scan.refresh}>
1271 Retry
1272 </Button>
1273 ) : null}
1274 <Button
1275 variant={scan.error !== null || scanTruncated ? "ghost-muted" : "default"}
1276 onClick={() => void onDone()}
1277 >
1278 {scan.error !== null || scanTruncated ? "Skip" : "Start coding"}
1279 </Button>
1280 </div>
1281 </StepShell>
1282 );
1283 }
1284
1285 if (choosing) {
1286 const selected = candidates.filter((candidate) => !deselected.has(candidate.path));
1287 return (
1288 <StepShell
1289 title="Choose your projects"
1290 onBack={() => setChoosing(false)}
1291 backDisabled={isImporting}
1292 description={`${candidates.length} found on ${machineLabel}.`}
1293 >
1294 {scanLimitNotice}
1295 <div className="mt-6 max-h-72 overflow-x-hidden overflow-y-auto border-y border-border">
1296 {candidates.map((candidate) => (
1297 <label
1298 key={candidate.path}
1299 className="flex min-h-12 cursor-pointer items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0 hover:bg-accent/50"
1300 >
1301 <Checkbox
1302 checked={!deselected.has(candidate.path)}
1303 onCheckedChange={(checked) => {
1304 setDeselected((previous) => {
1305 const next = new Set(previous);
1306 if (checked === true) next.delete(candidate.path);
1307 else next.add(candidate.path);
1308 return next;
1309 });
1310 }}
1311 />
1312 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
1313 {candidate.path}
1314 </span>
1315 <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block">
1316 {candidate.sources.map(formatSource).join(", ")} · {candidate.threadCount}{" "}
1317 {candidate.threadCount === 1 ? "thread" : "threads"}
1318 {candidate.lastActiveAt
1319 ? ` · ${formatRelativeTimeLabel(candidate.lastActiveAt)}`
1320 : ""}
1321 </span>
1322 </label>
1323 ))}
1324 </div>
1325 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
1326 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
1327 <Button
1328 variant="ghost-muted"
1329 disabled={isImporting}
1330 onClick={importError ? finishAfterImport : () => void onDone()}
1331 >
1332 {importError ? "Continue without the rest" : "Skip"}
1333 </Button>
1334 <Button
1335 disabled={isImporting || selected.length === 0}
1336 onClick={() => void runImport(selected)}
1337 >
1338 {isImporting ? "Importing..." : `Import ${selected.length}`}
1339 </Button>
1340 </div>
1341 </StepShell>
1342 );
1343 }
1344
1345 return (
1346 <StepShell
1347 title="Your recent projects"
1348 description={`${recent.length} ${recent.length === 1 ? "project" : "projects"} found on ${machineLabel}.${more > 0 ? ` ${more} more available.` : ""}`}
1349 onBack={onBack}
1350 backDisabled={isImporting}
1351 >
1352 {scanLimitNotice}
1353 <div className="mt-6 border-y border-border">
1354 {recent.slice(0, 4).map((candidate) => (
1355 <div
1356 key={candidate.path}
1357 className="flex min-h-12 items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0"
1358 >
1359 <CheckIcon className="size-3.5 shrink-0 text-success-foreground" />
1360 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
1361 {candidate.path}
1362 </span>
1363 <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block">
1364 {candidate.sources.map(formatSource).join(", ")}
1365 </span>
1366 </div>
1367 ))}
1368 {recent.length > 4 ? (
1369 <p className="px-1 py-3 text-xs text-muted-foreground">
1370 {recent.length - 4} more projects
1371 </p>
1372 ) : null}
1373 </div>
1374 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
1375 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
1376 <Button
1377 variant="ghost-muted"
1378 disabled={isImporting}
1379 onClick={importError ? finishAfterImport : () => void onDone()}
1380 >
1381 {importError ? "Continue without the rest" : "Skip"}
1382 </Button>
1383 <div className="flex items-center gap-2">
1384 <Button variant="ghost" disabled={isImporting} onClick={() => setChoosing(true)}>
1385 Choose
1386 </Button>
1387 <Button
1388 disabled={isImporting || recent.length === 0}
1389 onClick={() => void runImport(recent)}
1390 >
1391 {isImporting
1392 ? "Importing..."
1393 : `Import ${recent.length} ${recent.length === 1 ? "project" : "projects"}`}
1394 </Button>
1395 </div>
1396 </div>
1397 </StepShell>
1398 );
1399}
1400
1401// ── Shared bits ──────────────────────────────────────────────
1402
1403function StepShell({
1404 title,
1405 description,
1406 onBack,
1407 backDisabled = false,
1408 children,
1409}: {
1410 readonly title: string;
1411 readonly description?: string;
1412 readonly onBack?: () => void;
1413 readonly backDisabled?: boolean;
1414 readonly children?: React.ReactNode;
1415}) {
1416 return (
1417 <>
1418 {onBack ? (
1419 <Button
1420 className="mb-5 -ml-2"
1421 disabled={backDisabled}
1422 onClick={onBack}
1423 size="xs"
1424 variant="ghost-muted"
1425 >
1426 <ChevronLeftIcon className="size-3.5" />
1427 Back
1428 </Button>
1429 ) : null}
1430 <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">{title}</h1>
1431 {description ? (
1432 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
1433 ) : null}
1434 {children}
1435 </>
1436 );
1437}
1438
1439function CommandBlock({
1440 command,
1441 className,
1442 prominent = false,
1443}: {
1444 readonly command: string;
1445 readonly className?: string;
1446 readonly prominent?: boolean;
1447}) {
1448 const { copyToClipboard, isCopied } = useCopyToClipboard({
1449 timeout: 1500,
1450 target: "command",
1451 });
1452 return (
1453 <div
1454 className={cn(
1455 "flex items-center justify-between gap-3 border border-border bg-accent/50 font-mono",
1456 prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
1457 className,
1458 )}
1459 >
1460 <span className="min-w-0 truncate">
1461 <span className="mr-2 text-muted-foreground">$</span>
1462 {command}
1463 </span>
1464 <Button
1465 size="icon-xs"
1466 variant="ghost"
1467 aria-label="Copy command"
1468 onClick={() => copyToClipboard(command, undefined)}
1469 >
1470 {isCopied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
1471 </Button>
1472 </div>
1473 );
1474}
1475
1476function formatSource(source: "claudeAgent" | "codex"): string {
1477 return source === "claudeAgent" ? "Claude" : "Codex";
1478}
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx created+190
...@@ -0,0 +1,190 @@
1import {
2 DEFAULT_CLIENT_SETTINGS,
3 EnvironmentId,
4 ThreadId,
5 type ClientSettings,
6 type PreviewAutomationResponse,
7 type PreviewAutomationStreamEvent,
8 type PreviewOpenInput,
9 type PreviewSessionSnapshot,
10} from "@t3tools/contracts";
11import { AsyncResult, Atom } from "effect/unstable/reactivity";
12import { act } from "react";
13import { create, type ReactTestRenderer } from "react-test-renderer";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15
16import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings";
17import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";
18import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry";
19
20import { PreviewAutomationHosts } from "./PreviewAutomationHosts";
21
22const mocks = vi.hoisted(() => ({
23 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
24 setClientSettings: vi.fn(),
25 open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) =>
26 AsyncResult.success(snapshot),
27 ),
28 list: vi.fn(async () => AsyncResult.success(emptyList)),
29 resize: vi.fn(),
30 respond:
31 vi.fn<
32 (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void>
33 >(),
34 focus: vi.fn(async () => undefined),
35}));
36
37vi.mock("~/localApi", () => ({
38 ensureLocalApi: () => ({ persistence: mocks }),
39}));
40vi.mock("~/env", () => ({ isElectron: true }));
41vi.mock("~/state/environments", () => ({
42 useEnvironments: () => ({ environments: [{ environmentId }] }),
43}));
44vi.mock("~/state/preview", () => ({
45 previewEnvironment: {
46 automationRequests: () => requestsAtom,
47 list: () => listAtom,
48 open: mocks.open,
49 resize: mocks.resize,
50 respondToAutomation: mocks.respond,
51 focusAutomationHost: mocks.focus,
52 },
53}));
54vi.mock("~/state/use-atom-command", () => ({
55 useAtomCommand: (command: unknown) => command,
56}));
57vi.mock("~/state/use-atom-query-runner", () => ({
58 useAtomQueryRunner: () => mocks.list,
59}));
60vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } }));
61
62const environmentId = EnvironmentId.make("automation-environment");
63const threadId = ThreadId.make("automation-thread");
64const threadRef = { environmentId, threadId };
65const viewport = { _tag: "freeform", width: 1440, height: 900 } as const;
66const savedSettings: ClientSettings = {
67 ...DEFAULT_CLIENT_SETTINGS,
68 browserDefaultViewport: viewport,
69 browserDefaultProfileId: "work",
70 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
71};
72const snapshot: PreviewSessionSnapshot = {
73 threadId,
74 tabId: "automation-tab",
75 navStatus: { _tag: "Idle" },
76 canGoBack: false,
77 canGoForward: false,
78 viewport,
79 profileId: "work",
80 updatedAt: "2026-09-05T00:00:00.000Z",
81};
82const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 };
83const listAtom = Atom.make(AsyncResult.success(emptyList));
84const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
85 AsyncResult.initial(false),
86);
87const requestEvent: PreviewAutomationStreamEvent = {
88 type: "request",
89 connectionId: "automation-connection",
90 request: {
91 requestId: "open-request",
92 threadId,
93 operation: "open",
94 input: { open: false, reuseExistingTab: false },
95 timeoutMs: 15_000,
96 },
97};
98
99function deferred<A>() {
100 let resolve!: (value: A) => void;
101 const promise = new Promise<A>((complete) => {
102 resolve = complete;
103 });
104 return { promise, resolve };
105}
106
107let renderer: ReactTestRenderer | null = null;
108
109beforeEach(async () => {
110 vi.clearAllMocks();
111 mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings);
112 mocks.respond.mockReset();
113 __resetClientSettingsPersistenceForTests();
114 resetPreviewStateForTests();
115 appAtomRegistry.set(requestsAtom, AsyncResult.initial(false));
116 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
117 vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
118 vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] });
119 await act(() => {
120 renderer = create(
121 <AppAtomRegistryProvider>
122 <PreviewAutomationHosts />
123 </AppAtomRegistryProvider>,
124 );
125 });
126});
127
128afterEach(async () => {
129 await act(() => renderer?.unmount());
130 renderer = null;
131 resetPreviewStateForTests();
132 __resetClientSettingsPersistenceForTests();
133 vi.unstubAllGlobals();
134 vi.restoreAllMocks();
135});
136
137describe("PreviewAutomationHosts open", () => {
138 it("waits for saved settings before opening a tab with the configured profile and viewport", async () => {
139 const readStarted = deferred<void>();
140 const read = deferred<ClientSettings>();
141 const response = deferred<PreviewAutomationResponse>();
142 mocks.getClientSettings.mockImplementationOnce(() => {
143 readStarted.resolve();
144 return read.promise;
145 });
146 mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input));
147
148 await act(async () => {
149 appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent));
150 await readStarted.promise;
151 });
152 expect(mocks.open).not.toHaveBeenCalled();
153
154 await act(async () => {
155 read.resolve(savedSettings);
156 await response.promise;
157 });
158
159 expect(mocks.open).toHaveBeenCalledExactlyOnceWith({
160 environmentId,
161 input: { threadId, viewport, profileId: "work" },
162 });
163 expect(mocks.getClientSettings).toHaveBeenCalledOnce();
164 await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true });
165 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
166 expect(mocks.setClientSettings).not.toHaveBeenCalled();
167 });
168
169 it("reports a settings read failure without opening a tab", async () => {
170 vi.spyOn(console, "error").mockImplementation(() => undefined);
171 mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed"));
172 const response = deferred<PreviewAutomationResponse>();
173 mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input));
174
175 await act(async () => {
176 appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent));
177 await response.promise;
178 });
179
180 await expect(response.promise).resolves.toMatchObject({
181 requestId: "open-request",
182 ok: false,
183 error: { _tag: "PreviewAutomationExecutionError" },
184 });
185 expect(mocks.getClientSettings).toHaveBeenCalledOnce();
186 expect(mocks.open).not.toHaveBeenCalled();
187 expect(readThreadPreviewState(threadRef).snapshot).toBeNull();
188 expect(mocks.setClientSettings).not.toHaveBeenCalled();
189 });
190});
apps/web/src/components/preview/PreviewAutomationHosts.tsx+8-2
...@@ -41,7 +41,11 @@ import {...@@ -41,7 +41,11 @@ import {
41 acquireBrowserSurfaceActivity,41 acquireBrowserSurfaceActivity,
42 useBrowserSurfaceStore,42 useBrowserSurfaceStore,
43} from "~/browser/browserSurfaceStore";43} from "~/browser/browserSurfaceStore";
44import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";44import {
45 browserDefaultOpenProfileId,
46 browserDefaultOpenViewport,
47 resolveBrowserDefaults,
48} from "~/browser/browserDefaults";
45import { runBrowserViewportMutation } from "~/browser/browserViewportActions";49import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
46import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";50import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
47import { isElectron } from "~/env";51import { isElectron } from "~/env";
...@@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
412 const reusedExistingTab = activeTabId !== null;416 const reusedExistingTab = activeTabId !== null;
413 tabId = activeTabId;417 tabId = activeTabId;
414 if (!activeTabId) {418 if (!activeTabId) {
419 const defaults = await resolveBrowserDefaults();
415 const result = await open({420 const result = await open({
416 environmentId,421 environmentId,
417 input: {422 input: {
...@@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
419 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),424 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
420 // An agent that didn't state a size gets the user's425 // An agent that didn't state a size gets the user's
421 // configured default, same as a hand-opened tab.426 // configured default, same as a hand-opened tab.
422 viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()),427 viewport: browserDefaultOpenViewport(defaults),
428 profileId: browserDefaultOpenProfileId(defaults),
423 },429 },
424 });430 });
425 if (result._tag === "Failure") {431 if (result._tag === "Failure") {
apps/web/src/components/preview/PreviewView.tsx+15-1
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1"use client";1"use client";
22
3import { scopedThreadKey } from "@t3tools/client-runtime/environment";3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
5import {8import {
6 DEFAULT_BROWSER_PROFILE_ID,9 DEFAULT_BROWSER_PROFILE_ID,
7 FILL_PREVIEW_VIEWPORT,10 FILL_PREVIEW_VIEWPORT,
...@@ -46,6 +49,7 @@ import {...@@ -46,6 +49,7 @@ import {
46} from "~/browser/browserViewportActions";49} from "~/browser/browserViewportActions";
47import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";50import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
48import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";51import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
52import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
49import { PreviewUnreachable } from "./PreviewUnreachable";53import { PreviewUnreachable } from "./PreviewUnreachable";
50import { revealInFileExplorerLabel } from "./fileExplorerLabel";54import { revealInFileExplorerLabel } from "./fileExplorerLabel";
51import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";55import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
...@@ -186,6 +190,16 @@ export function PreviewView({...@@ -186,6 +190,16 @@ export function PreviewView({
186 return true;190 return true;
187 }191 }
188 const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl });192 const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl });
193 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
194 const error = squashAtomCommandFailure(result);
195 if (error instanceof BrowserSettingsReadError) {
196 toastManager.add({
197 type: "error",
198 title: "Unable to open browser",
199 description: error.message,
200 });
201 }
202 }
189 return result._tag === "Success";203 return result._tag === "Success";
190 },204 },
191 [open, runtimeTabId, threadRef],205 [open, runtimeTabId, threadRef],
apps/web/src/components/preview/addBrowserSurface.test.ts+3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_BROWSER_PROFILE_ID,2 DEFAULT_BROWSER_PROFILE_ID,
3 DEFAULT_CLIENT_SETTINGS,
3 FILL_PREVIEW_VIEWPORT,4 FILL_PREVIEW_VIEWPORT,
4 type PreviewOpenInput,5 type PreviewOpenInput,
5 type PreviewSessionSnapshot,6 type PreviewSessionSnapshot,
...@@ -14,6 +15,7 @@ import {...@@ -14,6 +15,7 @@ import {
14 resetPreviewStateForTests,15 resetPreviewStateForTests,
15} from "~/previewStateStore";16} from "~/previewStateStore";
16import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";17import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";
18import { __setClientSettingsForTests } from "~/hooks/useSettings";
1719
18import { addBrowserSurface } from "./addBrowserSurface";20import { addBrowserSurface } from "./addBrowserSurface";
1921
...@@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({...@@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({
32});34});
3335
34beforeEach(() => {36beforeEach(() => {
37 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
35 resetPreviewStateForTests();38 resetPreviewStateForTests();
36 useRightPanelStore.setState({ byThreadKey: {} });39 useRightPanelStore.setState({ byThreadKey: {} });
37});40});
apps/web/src/components/preview/addBrowserSurface.ts+2-2
...@@ -4,7 +4,7 @@ import {...@@ -4,7 +4,7 @@ import {
4} from "@t3tools/client-runtime/state/runtime";4} from "@t3tools/client-runtime/state/runtime";
5import type { ScopedThreadRef } from "@t3tools/contracts";5import type { ScopedThreadRef } from "@t3tools/contracts";
66
7import type { OpenPreviewMutation } from "~/browser/openFileInPreview";7import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview";
8import { useRightPanelStore } from "~/rightPanelStore";8import { useRightPanelStore } from "~/rightPanelStore";
99
10import { openPreviewSession } from "./openPreviewSession";10import { openPreviewSession } from "./openPreviewSession";
...@@ -15,7 +15,7 @@ export async function addBrowserSurface<E>(input: {...@@ -15,7 +15,7 @@ export async function addBrowserSurface<E>(input: {
15 readonly openPreview: OpenPreviewMutation<E>;15 readonly openPreview: OpenPreviewMutation<E>;
16 /** Omit to use the configured default profile. */16 /** Omit to use the configured default profile. */
17 readonly profileId?: string | undefined;17 readonly profileId?: string | undefined;
18}): Promise<AtomCommandResult<void, E>> {18}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
19 const result = await openPreviewSession({19 const result = await openPreviewSession({
20 openPreview: input.openPreview,20 openPreview: input.openPreview,
21 threadRef: input.threadRef,21 threadRef: input.threadRef,
apps/web/src/components/preview/openDiscoveredPort.ts+2-2
...@@ -5,7 +5,7 @@ import {...@@ -5,7 +5,7 @@ import {
5} from "@t3tools/client-runtime/state/runtime";5} from "@t3tools/client-runtime/state/runtime";
66
7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
8import type { OpenPreviewMutation } from "~/browser/openFileInPreview";8import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview";
9import { recordVisitForThread } from "~/browserHistoryStore";9import { recordVisitForThread } from "~/browserHistoryStore";
10import { useRightPanelStore } from "~/rightPanelStore";10import { useRightPanelStore } from "~/rightPanelStore";
11import { openPreviewSession } from "./openPreviewSession";11import { openPreviewSession } from "./openPreviewSession";
...@@ -14,7 +14,7 @@ export async function openDiscoveredPort<E>(input: {...@@ -14,7 +14,7 @@ export async function openDiscoveredPort<E>(input: {
14 readonly threadRef: ScopedThreadRef;14 readonly threadRef: ScopedThreadRef;
15 readonly port: DiscoveredLocalServer;15 readonly port: DiscoveredLocalServer;
16 readonly openPreview: OpenPreviewMutation<E>;16 readonly openPreview: OpenPreviewMutation<E>;
17}): Promise<AtomCommandResult<void, E>> {17}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
18 const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url);18 const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url);
19 const result = await openPreviewSession({19 const result = await openPreviewSession({
20 openPreview: input.openPreview,20 openPreview: input.openPreview,
apps/web/src/components/preview/openPreviewSession.test.ts+53-2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_BROWSER_PROFILE_ID,2 DEFAULT_BROWSER_PROFILE_ID,
3 DEFAULT_CLIENT_SETTINGS,
3 FILL_PREVIEW_VIEWPORT,4 FILL_PREVIEW_VIEWPORT,
4 type PreviewOpenInput,5 type PreviewOpenInput,
5 type PreviewSessionSnapshot,6 type PreviewSessionSnapshot,
...@@ -7,8 +8,11 @@ import {...@@ -7,8 +8,11 @@ import {
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1112
13import * as browserDefaults from "~/browser/browserDefaults";
14import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview";
15import { __setClientSettingsForTests } from "~/hooks/useSettings";
12import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";16import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";
1317
14import { openPreviewSession } from "./openPreviewSession";18import { openPreviewSession } from "./openPreviewSession";
...@@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = {...@@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = {
31 updatedAt: "2026-06-11T23:00:00.000Z",35 updatedAt: "2026-06-11T23:00:00.000Z",
32};36};
3337
34beforeEach(resetPreviewStateForTests);38beforeEach(() => {
39 resetPreviewStateForTests();
40 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
41});
42
43afterEach(() => {
44 vi.restoreAllMocks();
45});
3546
36describe("openPreviewSession", () => {47describe("openPreviewSession", () => {
37 it("creates an idle tab without recording a recently visited URL", async () => {48 it("creates an idle tab without recording a recently visited URL", async () => {
...@@ -88,4 +99,44 @@ describe("openPreviewSession", () => {...@@ -88,4 +99,44 @@ describe("openPreviewSession", () => {
88 expect(readThreadPreviewState(threadRef).snapshot).toBeNull();99 expect(readThreadPreviewState(threadRef).snapshot).toBeNull();
89 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);100 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
90 });101 });
102
103 it.each(["session", "link"] as const)(
104 "does not open a %s with unread settings and uses the saved profile on retry",
105 async (entryPoint) => {
106 const failure = new Error("Settings read failed");
107 vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure);
108 const viewport = { _tag: "freeform", width: 1280, height: 720 } as const;
109 __setClientSettingsForTests({
110 ...DEFAULT_CLIENT_SETTINGS,
111 browserDefaultViewport: viewport,
112 browserDefaultProfileId: "work",
113 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
114 });
115 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
116 const input = { openPreview, threadRef, url: "https://t3.chat/" };
117 const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview;
118
119 const result = await open(input);
120
121 expect(result._tag).toBe("Failure");
122 if (result._tag === "Failure") {
123 expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError);
124 expect(Cause.squash(result.cause)).toMatchObject({ cause: failure });
125 }
126 expect(openPreview).not.toHaveBeenCalled();
127 expect(readThreadPreviewState(threadRef).snapshot).toBeNull();
128 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
129
130 await expect(open(input)).resolves.toMatchObject({ _tag: "Success" });
131 expect(openPreview).toHaveBeenCalledExactlyOnceWith({
132 environmentId: threadRef.environmentId,
133 input: {
134 threadId: threadRef.threadId,
135 url: input.url,
136 viewport,
137 profileId: "work",
138 },
139 });
140 },
141 );
91});142});
apps/web/src/components/preview/openPreviewSession.ts+10-2
...@@ -6,12 +6,15 @@ import type {...@@ -6,12 +6,15 @@ import type {
6 ScopedThreadRef,6 ScopedThreadRef,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";8import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
9import * as Cause from "effect/Cause";
10import { AsyncResult } from "effect/unstable/reactivity";
911
10import {12import {
11 browserDefaultOpenProfileId,13 browserDefaultOpenProfileId,
12 browserDefaultOpenViewport,14 browserDefaultOpenViewport,
13 resolveBrowserDefaults,15 resolveBrowserDefaults,
14} from "~/browser/browserDefaults";16} from "~/browser/browserDefaults";
17import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
15import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";18import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1619
17interface OpenPreviewSessionInput<E> {20interface OpenPreviewSessionInput<E> {
...@@ -29,10 +32,15 @@ interface OpenPreviewSessionInput<E> {...@@ -29,10 +32,15 @@ interface OpenPreviewSessionInput<E> {
2932
30export async function openPreviewSession<E>(33export async function openPreviewSession<E>(
31 input: OpenPreviewSessionInput<E>,34 input: OpenPreviewSessionInput<E>,
32): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> {35): Promise<AtomCommandResult<PreviewSessionSnapshot, E | BrowserSettingsReadError>> {
33 // Resolved once: a tab opened before client settings hydrate would otherwise36 // Resolved once: a tab opened before client settings hydrate would otherwise
34 // be born at the schema defaults and never corrected.37 // be born at the schema defaults and never corrected.
35 const defaults = await resolveBrowserDefaults();38 const defaults = await resolveBrowserDefaults().catch(
39 (cause: unknown) => new BrowserSettingsReadError({ cause }),
40 );
41 if (defaults instanceof BrowserSettingsReadError) {
42 return AsyncResult.failure(Cause.fail(defaults));
43 }
36 const result = await input.openPreview({44 const result = await input.openPreview({
37 environmentId: input.threadRef.environmentId,45 environmentId: input.threadRef.environmentId,
38 input: {46 input: {
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+27
...@@ -68,6 +68,33 @@ afterEach(() => {...@@ -68,6 +68,33 @@ afterEach(() => {
68});68});
6969
70describe("openTerminalLinkInPreview", () => {70describe("openTerminalLinkInPreview", () => {
71 it.each(["target", "defaults"] as const)(
72 "does not open either browser when reading %s fails",
73 async (setting) => {
74 const failure = new Error("Settings read failed");
75 if (setting === "target") {
76 linkTargetMocks.preference.mockImplementationOnce(() => {
77 throw failure;
78 });
79 } else {
80 browserDefaultsMocks.resolve.mockRejectedValueOnce(failure);
81 }
82 const fallbackToBrowser = vi.fn();
83 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
84
85 await expect(
86 openTerminalLinkInPreview({
87 url: "https://example.com/docs",
88 threadRef,
89 openPreview,
90 fallbackToBrowser,
91 }),
92 ).rejects.toBe(failure);
93 expect(fallbackToBrowser).not.toHaveBeenCalled();
94 expect(openPreview).not.toHaveBeenCalled();
95 },
96 );
97
71 it("opens in the system browser while that is the configured target", async () => {98 it("opens in the system browser while that is the configured target", async () => {
72 linkTargetMocks.preference.mockReturnValue("system");99 linkTargetMocks.preference.mockReturnValue("system");
73 const fallbackToBrowser = vi.fn();100 const fallbackToBrowser = vi.fn();
apps/web/src/components/settings/providerStatus.test.ts created+71
...@@ -0,0 +1,71 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { getProviderSummary } from "./providerStatus";
5
6const provider: ServerProvider = {
7 instanceId: ProviderInstanceId.make("codex"),
8 driver: ProviderDriverKind.make("codex"),
9 enabled: true,
10 installed: true,
11 version: "1.0.0",
12 status: "ready",
13 auth: { status: "authenticated", label: "ChatGPT" },
14 checkedAt: "2026-08-23T00:00:00.000Z",
15 models: [],
16 slashCommands: [],
17 skills: [],
18};
19
20describe("getProviderSummary", () => {
21 it("reports ready providers with unknown authentication as available", () => {
22 expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({
23 headline: "Available",
24 detail: null,
25 });
26 });
27
28 it("does not hide a provider error behind a previous authenticated state", () => {
29 expect(
30 getProviderSummary({
31 ...provider,
32 status: "error",
33 message: "The provider process failed to start.",
34 }),
35 ).toEqual({
36 headline: "Unavailable",
37 detail: "The provider process failed to start.",
38 });
39 });
40
41 it("does not hide a provider warning behind an authenticated state", () => {
42 expect(
43 getProviderSummary({
44 ...provider,
45 status: "warning",
46 message: "The provider version is unsupported.",
47 }),
48 ).toEqual({
49 headline: "Needs attention",
50 detail: "The provider version is unsupported.",
51 });
52 });
53
54 it("keeps authentication failures actionable when their provider status is error", () => {
55 expect(
56 getProviderSummary({
57 ...provider,
58 status: "error",
59 auth: { status: "unauthenticated" },
60 message: "Run codex login.",
61 }),
62 ).toEqual({
63 headline: "Not authenticated",
64 detail: "Run codex login.",
65 });
66 });
67
68 it("treats a disabled provider status as disabled even before its enabled flag updates", () => {
69 expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled");
70 });
71});
apps/web/src/components/settings/providerStatus.ts+11-10
...@@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES;...@@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES;
26 * settings page. Prefers `provider.message` for server-supplied detail and26 * settings page. Prefers `provider.message` for server-supplied detail and
27 * falls back to generic phrasing when the server has not yet reported any27 * falls back to generic phrasing when the server has not yet reported any
28 * state — which happens before the first probe or when an instance names a28 * state — which happens before the first probe or when an instance names a
29 * driver this build does not ship.29 * driver this build does not ship. A ready provider without account metadata
30 * remains available and does not imply an authentication failure.
30 */31 */
31export function getProviderSummary(provider: ServerProvider | undefined) {32export function getProviderSummary(provider: ServerProvider | undefined) {
32 if (!provider) {33 if (!provider) {
...@@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) {...@@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
35 detail: "Waiting for the server to report installation and authentication details.",36 detail: "Waiting for the server to report installation and authentication details.",
36 };37 };
37 }38 }
38 if (!provider.enabled) {39 if (!provider.enabled || provider.status === "disabled") {
39 return {40 return {
40 headline: "Disabled",41 headline: "Disabled",
41 detail:42 detail:
...@@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) {...@@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
48 detail: provider.message ?? "CLI not detected on PATH.",49 detail: provider.message ?? "CLI not detected on PATH.",
49 };50 };
50 }51 }
51 if (provider.auth.status === "authenticated") {
52 const authLabel = provider.auth.label ?? provider.auth.type;
53 return {
54 headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated",
55 detail: provider.message ?? null,
56 };
57 }
58 if (provider.auth.status === "unauthenticated") {52 if (provider.auth.status === "unauthenticated") {
59 return {53 return {
60 headline: "Not authenticated",54 headline: "Not authenticated",
...@@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) {...@@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
74 detail: provider.message ?? "The provider failed its startup checks.",68 detail: provider.message ?? "The provider failed its startup checks.",
75 };69 };
76 }70 }
71 if (provider.auth.status === "authenticated") {
72 const authLabel = provider.auth.label ?? provider.auth.type;
73 return {
74 headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated",
75 detail: provider.message ?? null,
76 };
77 }
77 return {78 return {
78 headline: "Available",79 headline: "Available",
79 detail: provider.message ?? "Installed and ready, but authentication could not be verified.",80 detail: provider.message ?? null,
80 };81 };
81}82}
8283
apps/web/src/environments/primary/auth.ts+2
...@@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise<vo...@@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
353353
354 resolvedAuthenticatedGateState = null;354 resolvedAuthenticatedGateState = null;
355 await exchangeBootstrapCredential(trimmedCredential);355 await exchangeBootstrapCredential(trimmedCredential);
356 await waitForAuthenticatedSessionAfterBootstrap();
357 resolvedAuthenticatedGateState = { status: "authenticated" };
356 bootstrapPromise = null;358 bootstrapPromise = null;
357 stripPairingTokenFromUrl();359 stripPairingTokenFromUrl();
358}360}
apps/web/src/hooks/useLocalStorage.test.ts+21-2
...@@ -55,9 +55,28 @@ describe("local storage errors", () => {...@@ -55,9 +55,28 @@ describe("local storage errors", () => {
55 }55 }
56 });56 });
5757
58 it("preserves decode failure context", async () => {58 it("retries when access to browser storage becomes available", async () => {
59 const storage = createStorage();
60 storage.setItem("read-key", JSON.stringify("saved value"));
61 let blocked = true;
62 vi.stubGlobal("window", {
63 get localStorage() {
64 if (blocked) throw new Error("storage unavailable");
65 return storage;
66 },
67 });
68 const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage");
69
70 expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow(
71 LocalStorageOperationError,
72 );
73 blocked = false;
74 expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value");
75 });
76
77 it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => {
59 const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage(78 const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage(
60 createStorage({ getItem: () => "not-json" }),79 createStorage({ getItem: () => value }),
61 );80 );
6281
63 try {82 try {
apps/web/src/hooks/useLocalStorage.ts+20-20
...@@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass<LocalSto...@@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass<LocalSto
15 }15 }
16}16}
1717
18const isomorphicLocalStorage: Storage =18const fallbackStorage: Storage = (() => {
19 typeof window !== "undefined"19 const store = new Map<string, string>();
20 ? window.localStorage20 return {
21 : (function () {21 clear: () => store.clear(),
22 const store = new Map<string, string>();22 getItem: (_) => store.get(_) ?? null,
23 return {23 key: (_) => Record.keys(store).at(_) ?? null,
24 clear: () => store.clear(),24 get length() {
25 getItem: (_) => store.get(_) ?? null,25 return store.size;
26 key: (_) => Record.keys(store).at(_) ?? null,26 },
27 get length() {27 removeItem: (_) => store.delete(_),
28 return store.size;28 setItem: (_, value) => store.set(_, value),
29 },29 };
30 removeItem: (_) => store.delete(_),30})();
31 setItem: (_, value) => store.set(_, value),31
32 };32const getStorage = (): Storage =>
33 })();33 typeof window !== "undefined" ? window.localStorage : fallbackStorage;
3434
35const read = (key: string) => {35const read = (key: string) => {
36 try {36 try {
37 return isomorphicLocalStorage.getItem(key);37 return getStorage().getItem(key);
38 } catch (cause) {38 } catch (cause) {
39 throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause });39 throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause });
40 }40 }
...@@ -58,13 +58,13 @@ const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {...@@ -58,13 +58,13 @@ const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
5858
59export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {59export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
60 const item = read(key);60 const item = read(key);
61 return item ? decode(key, schema, item) : null;61 return item === null ? null : decode(key, schema, item);
62};62};
6363
64export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {64export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
65 const valueToSet = encode(key, schema, value);65 const valueToSet = encode(key, schema, value);
66 try {66 try {
67 isomorphicLocalStorage.setItem(key, valueToSet);67 getStorage().setItem(key, valueToSet);
68 } catch (cause) {68 } catch (cause) {
69 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });69 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });
70 }70 }
...@@ -72,7 +72,7 @@ export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema....@@ -72,7 +72,7 @@ export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.
7272
73export const removeLocalStorageItem = (key: string) => {73export const removeLocalStorageItem = (key: string) => {
74 try {74 try {
75 isomorphicLocalStorage.removeItem(key);75 getStorage().removeItem(key);
76 } catch (cause) {76 } catch (cause) {
77 throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause });77 throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause });
78 }78 }
apps/web/src/hooks/useSettings.test.ts+178-2
...@@ -3,12 +3,22 @@ import {...@@ -3,12 +3,22 @@ import {
3 ProviderDriverKind,3 ProviderDriverKind,
4 ProviderInstanceId,4 ProviderInstanceId,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";6import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings";
7import { beforeEach, describe, expect, it, vi } from "vite-plus/test";7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
8
9const persistenceMocks = vi.hoisted(() => ({
10 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
11 setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(),
12}));
13
14vi.mock("~/localApi", () => ({
15 ensureLocalApi: () => ({ persistence: persistenceMocks }),
16}));
817
9import {18import {
10 __resetClientSettingsPersistenceForTests,19 __resetClientSettingsPersistenceForTests,
11 __setClientSettingsForTests,20 __setClientSettingsForTests,
21 ensureClientSettingsHydrated,
12 getClientSettings,22 getClientSettings,
13 mergeEnvironmentSettings,23 mergeEnvironmentSettings,
14 persistClientSettingsPatch,24 persistClientSettingsPatch,
...@@ -17,9 +27,138 @@ import {...@@ -17,9 +27,138 @@ import {
17} from "./useSettings";27} from "./useSettings";
1828
19beforeEach(() => {29beforeEach(() => {
30 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);
31 persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined);
20 __resetClientSettingsPersistenceForTests();32 __resetClientSettingsPersistenceForTests();
21});33});
2234
35afterEach(() => {
36 vi.restoreAllMocks();
37});
38
39describe("client settings hydration", () => {
40 const savedSettings = {
41 ...DEFAULT_CLIENT_SETTINGS,
42 timestampFormat: "12-hour" as const,
43 favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }],
44 };
45 const onboardingCompletedAt = "2026-09-05T12:00:00.000Z";
46 const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt });
47
48 it("rejects completion after a failed read and preserves saved preferences on retry", async () => {
49 const failure = new Error("storage unavailable");
50 vi.spyOn(console, "error").mockImplementation(() => undefined);
51 persistenceMocks.getClientSettings
52 .mockRejectedValueOnce(failure)
53 .mockResolvedValue(savedSettings);
54
55 await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure);
56 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
57 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
58
59 const completedSettings = { ...savedSettings, onboardingCompletedAt };
60 await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings);
61 expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings);
62 expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2);
63 });
64
65 it("uses defaults only after storage confirms no saved settings exist", async () => {
66 const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt };
67
68 await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings);
69 expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce();
70 expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings);
71 });
72
73 it("holds patches until a pending read supplies the saved preferences", async () => {
74 let finishRead!: (settings: ClientSettings) => void;
75 persistenceMocks.getClientSettings.mockImplementationOnce(
76 () =>
77 new Promise((resolve) => {
78 finishRead = resolve;
79 }),
80 );
81 const persisted = new Promise<ClientSettings>((resolve) => {
82 persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => {
83 resolve(settings);
84 });
85 });
86
87 const hydration = ensureClientSettingsHydrated();
88 persistClientSettingsPatch({ wordWrap: false });
89 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
90 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
91
92 finishRead(savedSettings);
93 await hydration;
94 await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false });
95 expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce();
96 });
97
98 it("handles failed patch reads without writing and retries with the saved preferences", async () => {
99 const failure = new Error("storage unavailable");
100 vi.spyOn(console, "error").mockImplementation(() => undefined);
101 persistenceMocks.getClientSettings.mockRejectedValue(failure);
102
103 const hydration = ensureClientSettingsHydrated();
104 persistClientSettingsPatch({ wordWrap: false });
105 await expect(hydration).rejects.toBe(failure);
106 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
107
108 persistenceMocks.getClientSettings.mockResolvedValue(savedSettings);
109 const persisted = new Promise<ClientSettings>((resolve) => {
110 persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => {
111 resolve(settings);
112 });
113 });
114 persistClientSettingsPatch({ wordWrap: false });
115
116 await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false });
117 });
118
119 it("preserves patch order across hydration and a blocked completion write", async () => {
120 let finishRead!: (settings: ClientSettings) => void;
121 const read = new Promise<ClientSettings>((resolve) => {
122 finishRead = resolve;
123 });
124 persistenceMocks.getClientSettings.mockReturnValue(read);
125 let finishCompletionWrite!: () => void;
126 const blockedWrite = new Promise<void>((resolve) => {
127 finishCompletionWrite = resolve;
128 });
129 let signalCompletionWrite!: () => void;
130 const completionWriteStarted = new Promise<void>((resolve) => {
131 signalCompletionWrite = resolve;
132 });
133 let durableSettings: ClientSettings = savedSettings;
134 const persist = vi
135 .fn<(settings: ClientSettings) => Promise<void>>()
136 .mockImplementationOnce(async (settings) => {
137 signalCompletionWrite();
138 await blockedWrite;
139 durableSettings = settings;
140 })
141 .mockImplementation(async (settings) => {
142 durableSettings = settings;
143 });
144
145 const completion = persistClientSettingsUpdate(complete, persist);
146 persistClientSettingsPatch({ wordWrap: false }, persist);
147 finishRead(savedSettings);
148 await completionWriteStarted;
149 persistClientSettingsPatch({ wordWrap: true }, persist);
150 const finalWrite = persistClientSettingsUpdate((current) => current, persist);
151
152 finishCompletionWrite();
153 await completion;
154 await finalWrite;
155
156 const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true };
157 expect(getClientSettings()).toEqual(expected);
158 expect(durableSettings).toEqual(expected);
159 });
160});
161
23describe("persistClientSettingsUpdate", () => {162describe("persistClientSettingsUpdate", () => {
24 it("publishes the update only after persistence succeeds", async () => {163 it("publishes the update only after persistence succeeds", async () => {
25 let finishPersistence!: () => void;164 let finishPersistence!: () => void;
...@@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => {...@@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => {
245 expect(settings.sidebarAutoSettleOnMerge).toBe(false);384 expect(settings.sidebarAutoSettleOnMerge).toBe(false);
246 });385 });
247});386});
387
388describe("onboarding completion persistence", () => {
389 it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => {
390 const failure = new Error("disk full");
391 const persist = vi
392 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
393 .mockRejectedValueOnce(failure)
394 .mockResolvedValue(undefined);
395 const existingSettings = {
396 ...DEFAULT_CLIENT_SETTINGS,
397 timestampFormat: "12-hour" as const,
398 favorites: [
399 {
400 provider: ProviderInstanceId.make("codex_work"),
401 model: "gpt-5.6",
402 },
403 ],
404 };
405 __setClientSettingsForTests(existingSettings);
406 const onboardingCompletedAt = "2026-09-01T12:00:00.000Z";
407 const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({
408 ...current,
409 onboardingCompletedAt,
410 });
411
412 await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure);
413 expect(getClientSettings()).toBe(existingSettings);
414 expect(getClientSettings().onboardingCompletedAt).toBeNull();
415
416 const completedSettings = { ...existingSettings, onboardingCompletedAt };
417 await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual(
418 completedSettings,
419 );
420 expect(getClientSettings()).toEqual(completedSettings);
421 expect(persist).toHaveBeenLastCalledWith(completedSettings);
422 });
423});
apps/web/src/hooks/useSettings.ts+67-24
...@@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;...@@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
5454
55const clientSettingsListeners = new Set<() => void>();55const clientSettingsListeners = new Set<() => void>();
56const clientSettingsHydrationListeners = new Set<() => void>();56const clientSettingsHydrationListeners = new Set<() => void>();
57type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";
57let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;58let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
58let clientSettingsHydrated = false;59let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending";
59let clientSettingsHydrationPromise: Promise<void> | null = null;60let clientSettingsHydrationPromise: Promise<void> | null = null;
60let clientSettingsHydrationGeneration = 0;61let clientSettingsHydrationGeneration = 0;
61let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();62let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();
63let deferredClientSettingsPatchCount = 0;
6264
63function emitClientSettingsChange() {65function emitClientSettingsChange() {
64 for (const listener of clientSettingsListeners) {66 for (const listener of clientSettingsListeners) {
...@@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void {...@@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void {
81 emitClientSettingsChange();83 emitClientSettingsChange();
82}84}
8385
84function setClientSettingsHydrated(nextHydrated: boolean): void {86function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void {
85 if (clientSettingsHydrated === nextHydrated) {87 if (clientSettingsHydrationStatus === nextStatus) {
86 return;88 return;
87 }89 }
88 clientSettingsHydrated = nextHydrated;90 clientSettingsHydrationStatus = nextStatus;
89 emitClientSettingsHydrationChange();91 emitClientSettingsHydrationChange();
90}92}
9193
92function subscribeClientSettings(listener: () => void): () => void {94function subscribeClientSettings(listener: () => void): () => void {
93 clientSettingsListeners.add(listener);95 clientSettingsListeners.add(listener);
94 void hydrateClientSettings();96 void hydrateClientSettings().catch(() => undefined);
95 return () => {97 return () => {
96 clientSettingsListeners.delete(listener);98 clientSettingsListeners.delete(listener);
97 };99 };
98}100}
99101
100function getClientSettingsHydratedSnapshot(): boolean {102function getClientSettingsHydratedSnapshot(): boolean {
101 return clientSettingsHydrated;103 return clientSettingsHydrationStatus === "ready";
104}
105
106function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus {
107 return clientSettingsHydrationStatus;
102}108}
103109
104function subscribeClientSettingsHydration(listener: () => void): () => void {110function subscribeClientSettingsHydration(listener: () => void): () => void {
105 clientSettingsHydrationListeners.add(listener);111 clientSettingsHydrationListeners.add(listener);
106 void hydrateClientSettings();112 void hydrateClientSettings().catch(() => undefined);
107 return () => {113 return () => {
108 clientSettingsHydrationListeners.delete(listener);114 clientSettingsHydrationListeners.delete(listener);
109 };115 };
110}116}
111117
112async function hydrateClientSettings(): Promise<void> {118async function hydrateClientSettings(): Promise<void> {
113 if (clientSettingsHydrated) {119 if (clientSettingsHydrationStatus === "ready") {
114 return;120 return;
115 }121 }
116 if (clientSettingsHydrationPromise) {122 if (clientSettingsHydrationPromise) {
...@@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise<void> {...@@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise<void> {
118 }124 }
119125
120 const hydrationGeneration = clientSettingsHydrationGeneration;126 const hydrationGeneration = clientSettingsHydrationGeneration;
127 setClientSettingsHydrationStatus(
128 clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying"
129 ? "retrying"
130 : "pending",
131 );
121 const nextHydration = (async () => {132 const nextHydration = (async () => {
122 try {133 try {
123 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();134 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
...@@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise<void> {...@@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise<void> {
127 if (persistedSettings) {138 if (persistedSettings) {
128 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });139 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
129 }140 }
141 setClientSettingsHydrationStatus("ready");
130 } catch (error) {142 } catch (error) {
143 if (hydrationGeneration === clientSettingsHydrationGeneration) {
144 setClientSettingsHydrationStatus("failed");
145 }
131 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {146 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {
132 operation: "hydrate",147 operation: "hydrate",
133 ...safeErrorLogAttributes(error),148 ...safeErrorLogAttributes(error),
134 });149 });
135 } finally {150 throw error;
136 if (hydrationGeneration === clientSettingsHydrationGeneration) {
137 setClientSettingsHydrated(true);
138 }
139 }151 }
140 })();152 })();
141153
...@@ -165,15 +177,32 @@ export function persistClientSettingsPatch(...@@ -165,15 +177,32 @@ export function persistClientSettingsPatch(
165 patch: ClientSettingsPatch,177 patch: ClientSettingsPatch,
166 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,178 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
167): void {179): void {
168 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });180 // Patches queued before hydration must publish before newer optimistic patches.
169 void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch(181 const deferPatch =
170 (error) => {182 clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;
171 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {183 if (deferPatch) {
172 operation: "persist",184 deferredClientSettingsPatchCount += 1;
173 ...safeErrorLogAttributes(error),185 } else {
174 });186 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
175 },187 }
176 );188 void enqueueClientSettingsPersistence(async () => {
189 if (deferPatch) {
190 try {
191 if (clientSettingsHydrationStatus !== "ready") {
192 await hydrateClientSettings();
193 }
194 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
195 } finally {
196 deferredClientSettingsPatchCount -= 1;
197 }
198 }
199 await persist(getClientSettingsSnapshot());
200 }).catch((error) => {
201 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
202 operation: "persist",
203 ...safeErrorLogAttributes(error),
204 });
205 });
177}206}
178207
179/**208/**
...@@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate(...@@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate(
187 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,216 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
188): Promise<ClientSettings> {217): Promise<ClientSettings> {
189 return enqueueClientSettingsPersistence(async () => {218 return enqueueClientSettingsPersistence(async () => {
219 if (clientSettingsHydrationStatus !== "ready") {
220 await hydrateClientSettings();
221 }
190 for (;;) {222 for (;;) {
191 const current = getClientSettingsSnapshot();223 const current = getClientSettingsSnapshot();
192 const next = update(current);224 const next = update(current);
...@@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings {...@@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings {
234}266}
235267
236/**268/**
237 * Resolves once client settings have been read from disk.269 * Resolves after settings load or storage confirms no saved settings exist.
270 * Failed reads reject and remain retryable. They must not allow defaults to
271 * overwrite saved preferences.
238 *272 *
239 * The pre-hydration snapshot is just the schema defaults, so imperative paths273 * The pre-hydration snapshot is just the schema defaults, so imperative paths
240 * that open a preview must await this or they bake the built-in viewport, zoom274 * that open a preview must await this or they bake the built-in viewport, zoom
...@@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean {...@@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean {
252 );286 );
253}287}
254288
289export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus {
290 return useSyncExternalStore(
291 subscribeClientSettingsHydration,
292 getClientSettingsHydrationStatusSnapshot,
293 () => "pending",
294 );
295}
296
255function useClientSettingsValue(): ClientSettings {297function useClientSettingsValue(): ClientSettings {
256 return useSyncExternalStore(298 return useSyncExternalStore(
257 subscribeClientSettings,299 subscribeClientSettings,
...@@ -524,9 +566,10 @@ export function useUpdateClientSettings() {...@@ -524,9 +566,10 @@ export function useUpdateClientSettings() {
524export function __resetClientSettingsPersistenceForTests(): void {566export function __resetClientSettingsPersistenceForTests(): void {
525 clientSettingsHydrationGeneration += 1;567 clientSettingsHydrationGeneration += 1;
526 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;568 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
527 clientSettingsHydrated = false;569 clientSettingsHydrationStatus = "pending";
528 clientSettingsHydrationPromise = null;570 clientSettingsHydrationPromise = null;
529 clientSettingsPersistenceQueue = Promise.resolve();571 clientSettingsPersistenceQueue = Promise.resolve();
572 deferredClientSettingsPatchCount = 0;
530 clientSettingsListeners.clear();573 clientSettingsListeners.clear();
531 clientSettingsHydrationListeners.clear();574 clientSettingsHydrationListeners.clear();
532}575}
...@@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void {...@@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void {
534export function __setClientSettingsForTests(settings: ClientSettings): void {577export function __setClientSettingsForTests(settings: ClientSettings): void {
535 clientSettingsHydrationGeneration += 1;578 clientSettingsHydrationGeneration += 1;
536 clientSettingsSnapshot = settings;579 clientSettingsSnapshot = settings;
537 clientSettingsHydrated = true;580 clientSettingsHydrationStatus = "ready";
538 clientSettingsHydrationPromise = null;581 clientSettingsHydrationPromise = null;
539}582}
apps/web/src/hooks/useTheme.test.ts+197
...@@ -204,3 +204,200 @@ describe("theme failure handling", () => {...@@ -204,3 +204,200 @@ describe("theme failure handling", () => {
204 }204 }
205 });205 });
206});206});
207
208describe("onboarding theme", () => {
209 it("clears custom palettes and restores the latest selected theme", async () => {
210 const storage = createStorage();
211 const classes = new Set<string>();
212 const styleValues = new Map<string, string>();
213 const root = {
214 classList: {
215 add: (name: string) => classes.add(name),
216 remove: (name: string) => classes.delete(name),
217 toggle: (name: string, force?: boolean) => {
218 const next = force ?? !classes.has(name);
219 if (next) classes.add(name);
220 else classes.delete(name);
221 return next;
222 },
223 },
224 dataset: {} as Record<string, string>,
225 offsetHeight: 0,
226 style: {
227 backgroundColor: "",
228 removeProperty: (name: string) => styleValues.delete(name),
229 setProperty: (name: string, value: string) => styleValues.set(name, value),
230 },
231 };
232 vi.doMock("react", () => ({
233 useCallback: <A>(callback: A) => callback,
234 useEffect: () => undefined,
235 useSyncExternalStore: (
236 subscribe: (listener: () => void) => () => void,
237 getSnapshot: () => unknown,
238 ) => {
239 subscribe(() => undefined);
240 return getSnapshot();
241 },
242 }));
243 vi.stubGlobal("window", {
244 addEventListener: () => undefined,
245 localStorage: storage,
246 matchMedia: () => ({
247 matches: false,
248 addEventListener: () => undefined,
249 removeEventListener: () => undefined,
250 }),
251 removeEventListener: () => undefined,
252 });
253 vi.stubGlobal("document", {
254 body: { style: { backgroundColor: "" } },
255 createElement: () => ({ name: "", setAttribute: () => undefined }),
256 documentElement: root,
257 head: { append: () => undefined },
258 querySelector: () => null,
259 querySelectorAll: () => [],
260 });
261 vi.stubGlobal("getComputedStyle", () => ({
262 backgroundColor: "rgb(0, 0, 0)",
263 getPropertyValue: () => "",
264 }));
265 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
266 callback(0);
267 return 0;
268 });
269
270 const { EMBER_THEME, installCustomTheme } = await import("../themePalette");
271 const firstTheme = installCustomTheme({
272 ...EMBER_THEME,
273 id: "first-custom",
274 label: "First Custom",
275 });
276 const secondTheme = installCustomTheme({
277 ...EMBER_THEME,
278 id: "second-custom",
279 label: "Second Custom",
280 colors: { ...EMBER_THEME.colors, error: "#123456" },
281 });
282 storage.setItem("t3code:theme", firstTheme.id);
283
284 const { mountOnboardingTheme, useTheme } = await import("./useTheme");
285 expect(root.dataset.themeId).toBe(firstTheme.id);
286 expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error);
287
288 const cleanup = mountOnboardingTheme();
289 expect(root.dataset.themeId).toBeUndefined();
290 expect(styleValues.size).toBe(0);
291
292 expect(useTheme().setTheme(secondTheme.id)).toBe(true);
293 expect(root.dataset.themeId).toBeUndefined();
294 expect(styleValues.size).toBe(0);
295
296 cleanup();
297 expect(root.dataset.themeId).toBe(secondTheme.id);
298 expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error);
299 });
300
301 it("stays dark during storage changes and restores the latest saved theme", async () => {
302 const storage = createStorage();
303 storage.setItem("t3code:theme", "light");
304 const classes = new Set<string>();
305 const styleValues = new Map<string, string>();
306 const style = {
307 backgroundColor: "",
308 removeProperty: (name: string) => styleValues.delete(name),
309 setProperty: (name: string, value: string) => styleValues.set(name, value),
310 };
311 const root = {
312 classList: {
313 add: (name: string) => classes.add(name),
314 contains: (name: string) => classes.has(name),
315 remove: (name: string) => classes.delete(name),
316 toggle: (name: string, force?: boolean) => {
317 const next = force ?? !classes.has(name);
318 if (next) classes.add(name);
319 else classes.delete(name);
320 return next;
321 },
322 },
323 dataset: {} as Record<string, string>,
324 offsetHeight: 0,
325 style,
326 };
327 const body = { style: { backgroundColor: "" } };
328 let storageHandler: ((event: StorageEvent) => void) | undefined;
329 const setDesktopTheme = vi.fn().mockResolvedValue(undefined);
330 vi.doMock("react", () => ({
331 useCallback: <A>(callback: A) => callback,
332 useEffect: () => undefined,
333 useSyncExternalStore: (
334 subscribe: (listener: () => void) => () => void,
335 getSnapshot: () => unknown,
336 ) => {
337 subscribe(() => undefined);
338 return getSnapshot();
339 },
340 }));
341 vi.stubGlobal("window", {
342 addEventListener: (type: string, listener: (event: StorageEvent) => void) => {
343 if (type === "storage") storageHandler = listener;
344 },
345 localStorage: storage,
346 matchMedia: () => ({
347 matches: false,
348 addEventListener: () => undefined,
349 removeEventListener: () => undefined,
350 }),
351 removeEventListener: () => undefined,
352 desktopBridge: { setTheme: setDesktopTheme },
353 });
354 vi.stubGlobal("document", {
355 body,
356 createElement: () => ({ name: "", setAttribute: () => undefined }),
357 documentElement: root,
358 head: { append: () => undefined },
359 querySelector: () => null,
360 querySelectorAll: () => [],
361 });
362 vi.stubGlobal("getComputedStyle", () => ({
363 backgroundColor:
364 root.dataset.onboardingSurface !== undefined
365 ? "rgb(0, 0, 0)"
366 : classes.has("dark")
367 ? "rgb(10, 10, 10)"
368 : "rgb(255, 255, 255)",
369 getPropertyValue: () => "",
370 }));
371 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
372 callback(0);
373 return 0;
374 });
375
376 const { mountOnboardingTheme, useTheme } = await import("./useTheme");
377 expect(useTheme().resolvedTheme).toBe("light");
378 const cleanup = mountOnboardingTheme();
379
380 expect(root.dataset.onboardingSurface).toBe("");
381 expect(classes.has("dark")).toBe(true);
382 expect(root.style.backgroundColor).toBe("#000");
383 expect(body.style.backgroundColor).toBe("#000");
384 expect(useTheme().resolvedTheme).toBe("dark");
385 expect(setDesktopTheme).toHaveBeenLastCalledWith("dark");
386
387 storage.setItem("t3code:theme", "dark");
388 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
389 storage.setItem("t3code:theme", "light");
390 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
391 expect(classes.has("dark")).toBe(true);
392 expect(useTheme().resolvedTheme).toBe("dark");
393
394 cleanup();
395 expect(root.dataset.onboardingSurface).toBeUndefined();
396 expect(classes.has("dark")).toBe(false);
397 expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)");
398 expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)");
399 expect(storage.getItem("t3code:theme")).toBe("light");
400 expect(useTheme().resolvedTheme).toBe("light");
401 expect(setDesktopTheme).toHaveBeenLastCalledWith("light");
402 });
403});
apps/web/src/hooks/useTheme.ts+67-17
...@@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } {...@@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } {
98function themeHalvesSignature(halves: ThemeHalves | null): string {98function themeHalvesSignature(halves: ThemeHalves | null): string {
99 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;99 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
100}100}
101
102function isOnboardingThemeActive(): boolean {
103 return (
104 typeof document !== "undefined" &&
105 document.documentElement.dataset?.onboardingSurface !== undefined
106 );
107}
108
101const THEME_COLOR_META_NAME = "theme-color";109const THEME_COLOR_META_NAME = "theme-color";
102const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;110const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
103111
...@@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement {...@@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement {
292300
293export function syncBrowserChromeTheme() {301export function syncBrowserChromeTheme() {
294 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;302 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
303 const onboardingActive = isOnboardingThemeActive();
295 const rootStyles = getComputedStyle(document.documentElement);304 const rootStyles = getComputedStyle(document.documentElement);
296 const themeChromeColor = document.documentElement.dataset.themeId305 const themeChromeColor =
297 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))306 !onboardingActive && document.documentElement.dataset.themeId
298 : null;307 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
308 : null;
299 const surfaceColor = normalizeThemeColor(309 const surfaceColor = normalizeThemeColor(
300 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,310 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
301 );311 );
302 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);312 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
303 const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;313 const backgroundColor = onboardingActive
314 ? "#000"
315 : (themeChromeColor ?? surfaceColor ?? fallbackColor);
304 if (!backgroundColor) return;316 if (!backgroundColor) return;
305317
306 document.documentElement.style.backgroundColor = backgroundColor;318 document.documentElement.style.backgroundColor = backgroundColor;
...@@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() {...@@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() {
321333
322function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {334function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {
323 if (typeof document === "undefined" || typeof window === "undefined") return;335 if (typeof document === "undefined" || typeof window === "undefined") return;
336 const onboardingActive = isOnboardingThemeActive();
324 // Keep the editor's draft visible until an explicit refresh restores the selection.337 // Keep the editor's draft visible until an explicit refresh restores the selection.
325 if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return;338 if (
339 preservePreview &&
340 !onboardingActive &&
341 document.documentElement.dataset?.themeId === THEME_PREVIEW_ID
342 ) {
343 return;
344 }
326 const appearanceMode = readAppearanceModePreference(theme);345 const appearanceMode = readAppearanceModePreference(theme);
327 const followSystem = appearanceMode === "system";346 const followSystem = appearanceMode === "system";
328 const systemDark = followSystem ? getSystemDark() : false;347 const systemDark = followSystem ? getSystemDark() : false;
...@@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
334 lastAppliedTheme.appearanceMode === appearanceMode &&353 lastAppliedTheme.appearanceMode === appearanceMode &&
335 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)354 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)
336 ) {355 ) {
337 syncDesktopTheme(theme, followSystem, appearanceMode);356 if (onboardingActive) {
357 document.documentElement.classList.add("dark");
358 syncBrowserChromeTheme();
359 syncDesktopTheme("dark", false, "dark");
360 } else {
361 syncDesktopTheme(theme, followSystem, appearanceMode);
362 }
338 return;363 return;
339 }364 }
340365
...@@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
348 appearanceMode,373 appearanceMode,
349 themeHalves,374 themeHalves,
350 );375 );
351 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);376 if (onboardingActive) {
352 const isDark = resolvedAppearance === "dark";377 document.documentElement.classList.add("dark");
353 document.documentElement.classList.toggle("dark", isDark);378 } else {
379 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
380 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
381 }
354 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };382 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
355 syncBrowserChromeTheme();383 syncBrowserChromeTheme();
356 syncDesktopTheme(theme, followSystem, appearanceMode);384 if (onboardingActive) {
385 syncDesktopTheme("dark", false, "dark");
386 } else {
387 syncDesktopTheme(theme, followSystem, appearanceMode);
388 }
357 if (suppressTransitions) {389 if (suppressTransitions) {
358 // Force a reflow so the no-transitions class takes effect before removal390 // Force a reflow so the no-transitions class takes effect before removal
359 void document.documentElement.offsetHeight;391 void document.documentElement.offsetHeight;
...@@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
363 }395 }
364}396}
365397
398/** Own the document-wide dark palette used by the first-run wizard and its portals. */
399export function mountOnboardingTheme(): () => void {
400 if (typeof document === "undefined" || typeof window === "undefined") return () => {};
401
402 const root = document.documentElement;
403 applyThemePalette("dark", "dark");
404 root.dataset.onboardingSurface = "";
405 root.classList.add("dark");
406 syncBrowserChromeTheme();
407 syncDesktopTheme("dark", false, "dark");
408 emitChange();
409
410 return () => {
411 delete root.dataset.onboardingSurface;
412 root.style.backgroundColor = "";
413 document.body.style.backgroundColor = "";
414 lastAppliedTheme = null;
415 applyTheme(getStored(), { suppressTransitions: true, preservePreview: false });
416 emitChange();
417 };
418}
419
366export async function syncDesktopThemePreference(420export async function syncDesktopThemePreference(
367 bridge: DesktopThemeBridge,421 bridge: DesktopThemeBridge,
368 theme: Theme,422 theme: Theme,
...@@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot {...@@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot {
424 const systemDark = followSystem ? getSystemDark() : false;478 const systemDark = followSystem ? getSystemDark() : false;
425 const themeHalves = readStoredThemeHalves();479 const themeHalves = readStoredThemeHalves();
426480
427 const resolvedTheme = resolveThemeAppearance(481 const resolvedTheme = isOnboardingThemeActive()
428 theme,482 ? "dark"
429 systemDark,483 : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves);
430 followSystem,
431 appearanceMode,
432 themeHalves,
433 );
434 if (484 if (
435 lastSnapshot &&485 lastSnapshot &&
436 lastSnapshot.theme === theme &&486 lastSnapshot.theme === theme &&
apps/web/src/index.css+36-4
...@@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) {...@@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) {
1184 --terminal-selection-background: var(--app-theme-terminal-selection-background);1184 --terminal-selection-background: var(--app-theme-terminal-selection-background);
1185}1185}
11861186
1187/* The first-run flow owns the whole document so portaled menus and tooltips
1188 use the same fixed palette as the wizard. This follows the theme mapping so
1189 saved custom themes cannot override it while onboarding is mounted. */
1190html[data-onboarding-surface]:root {
1191 color-scheme: dark;
1192 --accent: #262626;
1193 --accent-foreground: #fff;
1194 --appearance-contrast-target: #fff;
1195 --app-chrome-background: #000;
1196 --background: #000;
1197 --border: #262626;
1198 --card: #000;
1199 --card-foreground: #fff;
1200 --destructive: var(--color-red-400);
1201 --foreground: #fff;
1202 --icon-muted: #a1a1aa;
1203 --input: #262626;
1204 --muted: #171717;
1205 --muted-foreground: #a1a1aa;
1206 --placeholder: #71717a;
1207 --popover: #171717;
1208 --popover-foreground: #fff;
1209 --ring: #737373;
1210 --secondary: #171717;
1211 --secondary-foreground: #fff;
1212 --secondary-label: #a1a1aa;
1213 --success-foreground: var(--color-emerald-400);
1214 --terminal-background: #000;
1215 --terminal-cursor: #fff;
1216 --terminal-foreground: #fff;
1217 --terminal-selection-background: rgb(255 255 255 / 20%);
1218}
1219
1187/* Theme-token dependency probes are restored synchronously, before paint. Keep1220/* Theme-token dependency probes are restored synchronously, before paint. Keep
1188 transitions from observing the temporary sentinel color in between. */1221 transitions from observing the temporary sentinel color in between. */
1189html[data-theme-token-probe],1222html[data-theme-token-probe],
...@@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] {...@@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] {
1385 }1418 }
1386}1419}
13871420
1388/* Contrast stays in ordinary custom properties so both Tailwind utilities and1421/* Recompute contrast wherever a subtree owns its own semantic color palette. */
1389 global/imperative chrome styles resolve the same adjusted role. Redeclare on
1390 the sidebar because it owns a local semantic palette. */
1391:root,1422:root,
1392[data-app-sidebar] {1423[data-app-sidebar],
1424[data-onboarding-surface] {
1393 --contrast-toolbar-foreground: color-mix(1425 --contrast-toolbar-foreground: color-mix(
1394 in oklab,1426 in oklab,
1395 color-mix(1427 color-mix(
apps/web/src/onboarding/firstRun.logic.test.ts created+514
...@@ -0,0 +1,514 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isFirstRunWorkspaceProvenanceAuthoritative,
5 isFreshFirstRunWorkspace,
6 resolveFirstRunDecision,
7 resolveHostedFirstRunDecision,
8 transitionFirstRunGateState,
9} from "./firstRun.logic";
10
11const freshWorkspace = {
12 enabled: true,
13 hydrated: true,
14 completed: false,
15 bootstrapped: true,
16 authoritative: true,
17 workspaceAuthoritative: true,
18 workspaceProvenanceAuthoritative: true,
19 catalogReady: true,
20 serverConfigAvailable: true,
21 workspaceFresh: true,
22 projectCount: 1,
23 threadCount: 1,
24} as const;
25
26describe("resolveFirstRunDecision", () => {
27 it("opens the wizard for an authoritative fresh workspace", () => {
28 expect(resolveFirstRunDecision(freshWorkspace)).toEqual({
29 decision: "wizard",
30 persistCompletion: false,
31 });
32 });
33
34 it("does not permanently complete onboarding from cached project counts", () => {
35 expect(
36 resolveFirstRunDecision({
37 ...freshWorkspace,
38 authoritative: false,
39 projectCount: 3,
40 workspaceFresh: false,
41 }),
42 ).toEqual({
43 decision: "app",
44 persistCompletion: false,
45 });
46 });
47
48 it("backfills completion once existing projects are confirmed by the server", () => {
49 expect(
50 resolveFirstRunDecision({
51 ...freshWorkspace,
52 projectCount: 3,
53 workspaceFresh: false,
54 }),
55 ).toEqual({
56 decision: "app",
57 persistCompletion: true,
58 });
59 });
60
61 it("does not complete onboarding while another environment is still bootstrapping", () => {
62 expect(
63 resolveFirstRunDecision({
64 ...freshWorkspace,
65 bootstrapped: false,
66 projectCount: 3,
67 workspaceFresh: false,
68 }),
69 ).toEqual({
70 decision: "app",
71 persistCompletion: false,
72 });
73 });
74
75 it("waits for managed environments before treating a workspace as new", () => {
76 expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({
77 decision: "pending",
78 persistCompletion: false,
79 });
80 });
81
82 it("does not complete onboarding before the environment catalog is ready", () => {
83 expect(
84 resolveFirstRunDecision({
85 ...freshWorkspace,
86 catalogReady: false,
87 projectCount: 3,
88 workspaceFresh: false,
89 }),
90 ).toEqual({
91 decision: "app",
92 persistCompletion: false,
93 });
94 });
95
96 it("does not complete onboarding before the server configuration is available", () => {
97 expect(
98 resolveFirstRunDecision({
99 ...freshWorkspace,
100 projectCount: 3,
101 serverConfigAvailable: false,
102 workspaceFresh: false,
103 }),
104 ).toEqual({
105 decision: "app",
106 persistCompletion: false,
107 });
108 });
109
110 it("does not complete onboarding from cached remote projects", () => {
111 expect(
112 resolveFirstRunDecision({
113 ...freshWorkspace,
114 projectCount: 3,
115 workspaceAuthoritative: false,
116 workspaceFresh: false,
117 }),
118 ).toEqual({
119 decision: "app",
120 persistCompletion: false,
121 });
122 });
123
124 it("does not complete onboarding from a single cached remote project", () => {
125 expect(
126 resolveFirstRunDecision({
127 ...freshWorkspace,
128 workspaceAuthoritative: false,
129 workspaceFresh: false,
130 }),
131 ).toEqual({
132 decision: "app",
133 persistCompletion: false,
134 });
135 });
136
137 it("waits for live data before judging a single cached project", () => {
138 expect(
139 resolveFirstRunDecision({
140 ...freshWorkspace,
141 authoritative: false,
142 workspaceFresh: false,
143 }),
144 ).toEqual({
145 decision: "pending",
146 persistCompletion: false,
147 });
148 });
149
150 it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => {
151 expect(
152 resolveFirstRunDecision({
153 ...freshWorkspace,
154 workspaceProvenanceAuthoritative: false,
155 }),
156 ).toEqual({
157 decision: "pending",
158 persistCompletion: false,
159 });
160 });
161
162 it("waits when the initial welcome is pending and opens the wizard after completion", () => {
163 const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({
164 welcomeReceived: true,
165 bootstrapStatus: "pending",
166 });
167 expect(
168 resolveFirstRunDecision({
169 ...freshWorkspace,
170 workspaceProvenanceAuthoritative: pendingProvenance,
171 }),
172 ).toEqual({ decision: "pending", persistCompletion: false });
173
174 const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({
175 welcomeReceived: true,
176 bootstrapStatus: "complete",
177 });
178 expect(
179 resolveFirstRunDecision({
180 ...freshWorkspace,
181 workspaceProvenanceAuthoritative: completedProvenance,
182 }),
183 ).toEqual({ decision: "wizard", persistCompletion: false });
184 });
185
186 it("does not wait for server data after onboarding is already complete", () => {
187 expect(
188 resolveFirstRunDecision({
189 ...freshWorkspace,
190 authoritative: false,
191 bootstrapped: false,
192 completed: true,
193 serverConfigAvailable: false,
194 }),
195 ).toEqual({
196 decision: "app",
197 persistCompletion: false,
198 });
199 });
200});
201
202describe("isFirstRunWorkspaceProvenanceAuthoritative", () => {
203 it("waits for cwd bootstrap when the initial catalog is empty", () => {
204 expect(
205 isFirstRunWorkspaceProvenanceAuthoritative({
206 welcomeReceived: true,
207 bootstrapStatus: "pending",
208 }),
209 ).toBe(false);
210 });
211
212 it("accepts an empty catalog after cwd bootstrap completes", () => {
213 expect(
214 isFirstRunWorkspaceProvenanceAuthoritative({
215 welcomeReceived: true,
216 bootstrapStatus: "complete",
217 }),
218 ).toBe(true);
219 });
220
221 it("waits for a welcome before treating an empty catalog as final", () => {
222 expect(
223 isFirstRunWorkspaceProvenanceAuthoritative({
224 welcomeReceived: false,
225 bootstrapStatus: null,
226 }),
227 ).toBe(false);
228 });
229
230 it("accepts a legacy welcome without bootstrap status", () => {
231 expect(
232 isFirstRunWorkspaceProvenanceAuthoritative({
233 welcomeReceived: true,
234 bootstrapStatus: null,
235 }),
236 ).toBe(true);
237 });
238});
239
240describe("transitionFirstRunGateState", () => {
241 it("shows recovery without mounting the app when evidence stalls", () => {
242 expect(
243 transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }),
244 ).toEqual({ decision: "pending", stalled: true });
245 });
246
247 it.each(["app", "wizard"] as const)(
248 "resolves stalled recovery to %s only after authoritative evidence",
249 (decision) => {
250 expect(
251 transitionFirstRunGateState(
252 { decision: "pending", stalled: true },
253 { type: "evidence", decision },
254 ),
255 ).toEqual({ decision, stalled: false });
256 },
257 );
258
259 it("keeps recovery visible while evidence remains pending", () => {
260 const state = { decision: "pending", stalled: true } as const;
261 expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe(
262 state,
263 );
264 });
265
266 it("allows authoritative wizard evidence to replace an app decision", () => {
267 expect(
268 transitionFirstRunGateState(
269 { decision: "app", stalled: false },
270 { type: "evidence", decision: "wizard" },
271 ),
272 ).toEqual({ decision: "wizard", stalled: false });
273 });
274});
275
276describe("resolveHostedFirstRunDecision", () => {
277 it("keeps the shell hidden until client settings are hydrated", () => {
278 expect(
279 resolveHostedFirstRunDecision({
280 hydrated: false,
281 completed: false,
282 catalogReady: true,
283 environmentCount: 0,
284 }),
285 ).toEqual({
286 decision: "pending",
287 persistCompletion: false,
288 });
289 });
290
291 it("waits for the saved environment catalog before judging a hosted install", () => {
292 expect(
293 resolveHostedFirstRunDecision({
294 hydrated: true,
295 completed: false,
296 catalogReady: false,
297 environmentCount: 0,
298 }),
299 ).toEqual({
300 decision: "pending",
301 persistCompletion: false,
302 });
303 });
304
305 it("opens onboarding when a hosted install has no saved environments", () => {
306 expect(
307 resolveHostedFirstRunDecision({
308 hydrated: true,
309 completed: false,
310 catalogReady: true,
311 environmentCount: 0,
312 }),
313 ).toEqual({
314 decision: "wizard",
315 persistCompletion: false,
316 });
317 });
318
319 it("backfills onboarding for a hosted install with saved environments", () => {
320 expect(
321 resolveHostedFirstRunDecision({
322 hydrated: true,
323 completed: false,
324 catalogReady: true,
325 environmentCount: 1,
326 }),
327 ).toEqual({
328 decision: "app",
329 persistCompletion: true,
330 });
331 });
332
333 it("opens the app immediately after hosted onboarding is complete", () => {
334 expect(
335 resolveHostedFirstRunDecision({
336 hydrated: true,
337 completed: true,
338 catalogReady: false,
339 environmentCount: 0,
340 }),
341 ).toEqual({
342 decision: "app",
343 persistCompletion: false,
344 });
345 });
346});
347
348const primaryEnvironmentId = "primary-environment";
349const bootstrapProject = {
350 id: "bootstrap-project",
351 environmentId: primaryEnvironmentId,
352 workspaceRoot: "/projects/current",
353};
354const bootstrapThread = {
355 id: "bootstrap-thread",
356 projectId: bootstrapProject.id,
357 environmentId: primaryEnvironmentId,
358 latestTurn: null,
359 latestUserMessageAt: null,
360 session: null,
361};
362
363describe("isFreshFirstRunWorkspace", () => {
364 it("accepts an empty workspace", () => {
365 expect(
366 isFreshFirstRunWorkspace({
367 primaryEnvironmentId,
368 serverCwd: "/projects/current",
369 projects: [],
370 threads: [],
371 }),
372 ).toBe(true);
373 });
374
375 it("accepts only the unused project and thread created from the server cwd", () => {
376 expect(
377 isFreshFirstRunWorkspace({
378 primaryEnvironmentId,
379 serverCwd: "/projects/current/",
380 bootstrapProjectId: bootstrapProject.id,
381 bootstrapThreadId: bootstrapThread.id,
382 bootstrapProjectCreated: true,
383 bootstrapThreadCreated: true,
384 projects: [bootstrapProject],
385 threads: [bootstrapThread],
386 }),
387 ).toBe(true);
388 });
389
390 it("rejects an existing unused cwd project and thread reused by startup", () => {
391 expect(
392 isFreshFirstRunWorkspace({
393 primaryEnvironmentId,
394 serverCwd: "/projects/current",
395 bootstrapProjectId: bootstrapProject.id,
396 bootstrapThreadId: bootstrapThread.id,
397 bootstrapProjectCreated: false,
398 bootstrapThreadCreated: false,
399 projects: [bootstrapProject],
400 threads: [bootstrapThread],
401 }),
402 ).toBe(false);
403 });
404
405 it("rejects a nonempty workspace when an older server omits creation provenance", () => {
406 expect(
407 isFreshFirstRunWorkspace({
408 primaryEnvironmentId,
409 serverCwd: "/projects/current",
410 bootstrapProjectId: bootstrapProject.id,
411 bootstrapThreadId: bootstrapThread.id,
412 projects: [bootstrapProject],
413 threads: [bootstrapThread],
414 }),
415 ).toBe(false);
416 });
417
418 it("normalizes Windows project paths before checking the bootstrap workspace", () => {
419 expect(
420 isFreshFirstRunWorkspace({
421 primaryEnvironmentId,
422 serverCwd: "C:\\Projects\\Current\\",
423 bootstrapProjectId: bootstrapProject.id,
424 bootstrapThreadId: bootstrapThread.id,
425 bootstrapProjectCreated: true,
426 bootstrapThreadCreated: true,
427 projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }],
428 threads: [bootstrapThread],
429 }),
430 ).toBe(true);
431 });
432
433 it("rejects projects from another environment even when their paths match", () => {
434 expect(
435 isFreshFirstRunWorkspace({
436 primaryEnvironmentId,
437 serverCwd: "/projects/current",
438 projects: [{ ...bootstrapProject, environmentId: "remote-environment" }],
439 threads: [],
440 }),
441 ).toBe(false);
442 });
443
444 it("rejects threads from another environment", () => {
445 expect(
446 isFreshFirstRunWorkspace({
447 primaryEnvironmentId,
448 serverCwd: "/projects/current",
449 projects: [bootstrapProject],
450 threads: [{ ...bootstrapThread, environmentId: "remote-environment" }],
451 }),
452 ).toBe(false);
453 });
454
455 it("rejects a thread that does not belong to the bootstrap project", () => {
456 expect(
457 isFreshFirstRunWorkspace({
458 primaryEnvironmentId,
459 serverCwd: "/projects/current",
460 projects: [bootstrapProject],
461 threads: [{ ...bootstrapThread, projectId: "another-project" }],
462 }),
463 ).toBe(false);
464 });
465
466 it("rejects a thread when there is no bootstrap project", () => {
467 expect(
468 isFreshFirstRunWorkspace({
469 primaryEnvironmentId,
470 serverCwd: "/projects/current",
471 projects: [],
472 threads: [bootstrapThread],
473 }),
474 ).toBe(false);
475 });
476
477 it("rejects a bootstrap thread that already has a user message", () => {
478 expect(
479 isFreshFirstRunWorkspace({
480 primaryEnvironmentId,
481 serverCwd: "/projects/current",
482 projects: [bootstrapProject],
483 threads: [
484 {
485 ...bootstrapThread,
486 latestUserMessageAt: "2026-08-23T12:00:00.000Z",
487 },
488 ],
489 }),
490 ).toBe(false);
491 });
492
493 it("rejects a bootstrap thread that has started a turn", () => {
494 expect(
495 isFreshFirstRunWorkspace({
496 primaryEnvironmentId,
497 serverCwd: "/projects/current",
498 projects: [bootstrapProject],
499 threads: [{ ...bootstrapThread, latestTurn: { id: "first-turn" } }],
500 }),
501 ).toBe(false);
502 });
503
504 it("rejects a bootstrap thread that has a provider session", () => {
505 expect(
506 isFreshFirstRunWorkspace({
507 primaryEnvironmentId,
508 serverCwd: "/projects/current",
509 projects: [bootstrapProject],
510 threads: [{ ...bootstrapThread, session: { status: "ready" } }],
511 }),
512 ).toBe(false);
513 });
514});
apps/web/src/onboarding/firstRun.logic.ts created+184
...@@ -0,0 +1,184 @@
1import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
2
3export type FirstRunDecision = "pending" | "app" | "wizard";
4
5export interface FirstRunGateState {
6 readonly decision: FirstRunDecision;
7 readonly stalled: boolean;
8}
9
10type FirstRunGateEvent =
11 | { readonly type: "evidence"; readonly decision: FirstRunDecision }
12 | { readonly type: "timeout" };
13
14interface FirstRunWorkspaceInput {
15 readonly primaryEnvironmentId: string | null;
16 readonly serverCwd: string | null;
17 readonly bootstrapProjectId?: string | undefined;
18 readonly bootstrapThreadId?: string | undefined;
19 readonly bootstrapProjectCreated?: boolean | undefined;
20 readonly bootstrapThreadCreated?: boolean | undefined;
21 readonly projects: ReadonlyArray<{
22 readonly id: string;
23 readonly environmentId: string;
24 readonly workspaceRoot: string;
25 }>;
26 readonly threads: ReadonlyArray<{
27 readonly id: string;
28 readonly projectId: string;
29 readonly environmentId: string;
30 readonly latestTurn: unknown;
31 readonly latestUserMessageAt: string | null;
32 readonly session: unknown;
33 }>;
34}
35
36interface FirstRunDecisionInput {
37 readonly enabled: boolean;
38 readonly hydrated: boolean;
39 readonly completed: boolean;
40 readonly bootstrapped: boolean;
41 readonly authoritative: boolean;
42 readonly workspaceAuthoritative: boolean;
43 readonly workspaceProvenanceAuthoritative: boolean;
44 readonly catalogReady: boolean;
45 readonly serverConfigAvailable: boolean;
46 readonly workspaceFresh: boolean;
47 readonly projectCount: number;
48 readonly threadCount: number;
49}
50
51interface HostedFirstRunDecisionInput {
52 readonly hydrated: boolean;
53 readonly completed: boolean;
54 readonly catalogReady: boolean;
55 readonly environmentCount: number;
56}
57
58export function isFirstRunWorkspaceProvenanceAuthoritative(input: {
59 readonly welcomeReceived: boolean;
60 readonly bootstrapStatus: "pending" | "complete" | null;
61}): boolean {
62 // An empty catalog is not final while cwd auto-bootstrap is pending. Older
63 // servers omit bootstrapStatus, so a received welcome with null stays valid.
64 return input.welcomeReceived && input.bootstrapStatus !== "pending";
65}
66
67/** Keeps the authenticated app unmounted until workspace evidence settles. */
68export function transitionFirstRunGateState(
69 state: FirstRunGateState,
70 event: FirstRunGateEvent,
71): FirstRunGateState {
72 if (event.type === "timeout") {
73 return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state;
74 }
75
76 if (
77 state.decision === "wizard" ||
78 event.decision === "pending" ||
79 (state.decision === "app" && event.decision !== "wizard")
80 ) {
81 return state;
82 }
83
84 return { decision: event.decision, stalled: false };
85}
86
87/** Only a project and thread created by this startup count as a fresh nonempty workspace. */
88export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean {
89 if (input.projects.length > 1 || input.threads.length > 1) {
90 return false;
91 }
92
93 const bootstrapProject = input.projects[0];
94 if (bootstrapProject !== undefined) {
95 if (
96 input.bootstrapProjectCreated !== true ||
97 input.bootstrapProjectId !== bootstrapProject.id ||
98 input.serverCwd === null ||
99 bootstrapProject.environmentId !== input.primaryEnvironmentId ||
100 normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !==
101 normalizeProjectPathForComparison(input.serverCwd)
102 ) {
103 return false;
104 }
105 }
106
107 const bootstrapThread = input.threads[0];
108 if (bootstrapThread === undefined) {
109 return true;
110 }
111
112 return (
113 bootstrapProject !== undefined &&
114 input.bootstrapThreadCreated === true &&
115 input.bootstrapThreadId === bootstrapThread.id &&
116 bootstrapThread.environmentId === input.primaryEnvironmentId &&
117 bootstrapThread.projectId === bootstrapProject.id &&
118 bootstrapThread.latestTurn === null &&
119 bootstrapThread.latestUserMessageAt === null &&
120 bootstrapThread.session === null
121 );
122}
123
124/** Cached projects may open the app, but only live workspace data may complete onboarding. */
125export function resolveFirstRunDecision(input: FirstRunDecisionInput): {
126 readonly decision: FirstRunDecision;
127 readonly persistCompletion: boolean;
128} {
129 if (!input.enabled || (input.hydrated && input.completed)) {
130 return { decision: "app", persistCompletion: false };
131 }
132
133 if (!input.hydrated) {
134 return { decision: "pending", persistCompletion: false };
135 }
136
137 if (input.projectCount > 1 || input.threadCount > 1) {
138 return {
139 decision: "app",
140 persistCompletion:
141 input.bootstrapped &&
142 input.authoritative &&
143 input.workspaceAuthoritative &&
144 input.catalogReady &&
145 input.serverConfigAvailable,
146 };
147 }
148
149 if (
150 !input.bootstrapped ||
151 !input.authoritative ||
152 !input.workspaceProvenanceAuthoritative ||
153 !input.catalogReady ||
154 !input.serverConfigAvailable
155 ) {
156 return { decision: "pending", persistCompletion: false };
157 }
158
159 return input.workspaceFresh
160 ? { decision: "wizard", persistCompletion: false }
161 : { decision: "app", persistCompletion: input.workspaceAuthoritative };
162}
163
164/** Hosted onboarding depends on saved environments because there is no primary server. */
165export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): {
166 readonly decision: FirstRunDecision;
167 readonly persistCompletion: boolean;
168} {
169 if (!input.hydrated) {
170 return { decision: "pending", persistCompletion: false };
171 }
172
173 if (input.completed) {
174 return { decision: "app", persistCompletion: false };
175 }
176
177 if (!input.catalogReady) {
178 return { decision: "pending", persistCompletion: false };
179 }
180
181 return input.environmentCount === 0
182 ? { decision: "wizard", persistCompletion: false }
183 : { decision: "app", persistCompletion: true };
184}
apps/web/src/onboarding/firstRun.ts created+16
...@@ -0,0 +1,16 @@
1import { useCallback } from "react";
2
3import { ensureClientSettingsHydrated, persistClientSettingsUpdate } from "../hooks/useSettings";
4
5/**
6 * Marks first-run onboarding finished (or skipped) so FirstRunGate never
7 * routes to the welcome wizard again. The gate itself lives in
8 * `components/onboarding/FirstRunGate.tsx`.
9 */
10export function useCompleteOnboarding(): () => Promise<void> {
11 return useCallback(async () => {
12 await ensureClientSettingsHydrated();
13 const onboardingCompletedAt = new Date().toISOString();
14 await persistClientSettingsUpdate((current) => ({ ...current, onboardingCompletedAt }));
15 }, []);
16}
apps/web/src/onboarding/projectImport.logic.test.ts created+245
...@@ -0,0 +1,245 @@
1import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 partitionOnboardingProjects,
6 resolveOnboardingLandingProject,
7 resolveOnboardingProjectId,
8} from "./projectImport.logic";
9
10const now = Date.parse("2026-08-22T12:00:00.000Z");
11
12function candidate(
13 path: string,
14 overrides: Partial<AgentSessionProjectCandidate> = {},
15): AgentSessionProjectCandidate {
16 return {
17 title: path.split("/").at(-1) ?? path,
18 path,
19 sources: ["codex"],
20 threadCount: 1,
21 lastActiveAt: "2026-08-20T12:00:00.000Z",
22 alreadyImported: false,
23 ...overrides,
24 };
25}
26
27describe("partitionOnboardingProjects", () => {
28 it("keeps existing projects available for thread history import", () => {
29 const imported = candidate("/projects/current", { alreadyImported: true });
30 const available = candidate("/projects/other");
31
32 expect(partitionOnboardingProjects([imported, available], now)).toEqual({
33 available: [imported, available],
34 recent: [imported, available],
35 });
36 });
37
38 it("keeps projects older than 30 days out of the default selection", () => {
39 const recent = candidate("/projects/recent");
40 const older = candidate("/projects/older", {
41 lastActiveAt: "2026-07-01T12:00:00.000Z",
42 });
43
44 expect(partitionOnboardingProjects([recent, older], now)).toEqual({
45 available: [recent, older],
46 recent: [recent],
47 });
48 });
49
50 it("keeps future activity out of the default selection", () => {
51 const recent = candidate("/projects/recent");
52 const future = candidate("/projects/future", {
53 lastActiveAt: "2026-08-23T12:00:00.000Z",
54 });
55
56 expect(partitionOnboardingProjects([recent, future], now)).toEqual({
57 available: [recent, future],
58 recent: [recent],
59 });
60 });
61});
62
63describe("resolveOnboardingProjectId", () => {
64 const localEnvironmentId = EnvironmentId.make("local");
65 const remoteEnvironmentId = EnvironmentId.make("remote");
66 const localProjectId = ProjectId.make("local-project");
67
68 it("uses the scanned project ID before the project reaches the client", () => {
69 expect(
70 resolveOnboardingProjectId(
71 [],
72 localEnvironmentId,
73 candidate("/projects/repo", { projectId: localProjectId }),
74 ),
75 ).toBe(localProjectId);
76 });
77
78 it("uses the scanned project ID when the client still has an older project at that root", () => {
79 expect(
80 resolveOnboardingProjectId(
81 [
82 {
83 id: ProjectId.make("stale-project"),
84 environmentId: localEnvironmentId,
85 workspaceRoot: "/projects/repo",
86 },
87 ],
88 localEnvironmentId,
89 candidate("/projects/repo", { projectId: localProjectId }),
90 ),
91 ).toBe(localProjectId);
92 });
93
94 it("returns null to create a project when neither the scan nor the client has a project ID", () => {
95 expect(
96 resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")),
97 ).toBeNull();
98 });
99
100 it("finds an existing project by normalized root in the target environment", () => {
101 expect(
102 resolveOnboardingProjectId(
103 [
104 {
105 id: ProjectId.make("remote-project"),
106 environmentId: remoteEnvironmentId,
107 workspaceRoot: "C:\\Work\\Repo",
108 },
109 {
110 id: localProjectId,
111 environmentId: localEnvironmentId,
112 workspaceRoot: "C:\\Work\\Repo\\",
113 },
114 ],
115 localEnvironmentId,
116 candidate("c:/work/repo"),
117 ),
118 ).toBe(localProjectId);
119 });
120
121 it("does not reuse a project from another environment", () => {
122 expect(
123 resolveOnboardingProjectId(
124 [
125 {
126 id: ProjectId.make("remote-project"),
127 environmentId: remoteEnvironmentId,
128 workspaceRoot: "/projects/repo",
129 },
130 ],
131 localEnvironmentId,
132 candidate("/projects/repo"),
133 ),
134 ).toBeNull();
135 });
136
137 it("finds an alias after the scanner returns its persisted project root", () => {
138 expect(
139 resolveOnboardingProjectId(
140 [
141 {
142 id: localProjectId,
143 environmentId: localEnvironmentId,
144 workspaceRoot: "/real/projects/repo",
145 },
146 ],
147 localEnvironmentId,
148 candidate("/real/projects/repo"),
149 ),
150 ).toBe(localProjectId);
151 });
152
153 it("finds the current root owner when the scan has no project ID", () => {
154 const recreatedProjectId = ProjectId.make("recreated-project");
155 expect(
156 resolveOnboardingProjectId(
157 [
158 {
159 id: localProjectId,
160 environmentId: localEnvironmentId,
161 workspaceRoot: "/projects/other",
162 },
163 {
164 id: recreatedProjectId,
165 environmentId: localEnvironmentId,
166 workspaceRoot: "/projects/repo",
167 },
168 ],
169 localEnvironmentId,
170 candidate("/projects/repo"),
171 ),
172 ).toBe(recreatedProjectId);
173 });
174
175 it("does not reuse a moved project when the scan has no project ID", () => {
176 expect(
177 resolveOnboardingProjectId(
178 [
179 {
180 id: localProjectId,
181 environmentId: localEnvironmentId,
182 workspaceRoot: "/projects/moved",
183 },
184 ],
185 localEnvironmentId,
186 candidate("/projects/repo"),
187 ),
188 ).toBeNull();
189 });
190});
191
192describe("resolveOnboardingLandingProject", () => {
193 it("skips a failed first project for a later project with imported history", () => {
194 expect(
195 resolveOnboardingLandingProject(
196 ["/projects/failed", "/projects/imported"],
197 new Map([["/projects/imported", "imported"]]),
198 new Map([["/projects/imported", "imported"]]),
199 ),
200 ).toBe("imported");
201 });
202
203 it("prefers a partial first import that added history", () => {
204 expect(
205 resolveOnboardingLandingProject(
206 ["/projects/partial", "/projects/complete"],
207 new Map([["/projects/partial", "partial"]]),
208 new Map([["/projects/complete", "complete"]]),
209 ),
210 ).toBe("partial");
211 });
212
213 it("uses a completed zero-history project when no import added history", () => {
214 expect(
215 resolveOnboardingLandingProject(
216 ["/projects/empty", "/projects/failed"],
217 new Map(),
218 new Map([["/projects/empty", "empty"]]),
219 ),
220 ).toBe("empty");
221 });
222
223 it("keeps an earlier successful import available on retry", () => {
224 expect(
225 resolveOnboardingLandingProject(
226 ["/projects/imported", "/projects/retry"],
227 new Map([["/projects/imported", "imported"]]),
228 new Map([["/projects/imported", "imported"]]),
229 ),
230 ).toBe("imported");
231 });
232
233 it("ignores cached successes outside the current retry selection", () => {
234 expect(
235 resolveOnboardingLandingProject(
236 ["/projects/current"],
237 new Map([["/projects/previous", "previous"]]),
238 new Map([
239 ["/projects/previous", "previous"],
240 ["/projects/current", "current"],
241 ]),
242 ),
243 ).toBe("current");
244 });
245});
apps/web/src/onboarding/projectImport.logic.ts created+55
...@@ -0,0 +1,55 @@
1import { findProjectByPath } from "@t3tools/client-runtime/state/projects";
2import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";
3
4const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
5
6/** Existing projects still need their agent history imported, so every scan candidate is offered. */
7export function partitionOnboardingProjects(
8 candidates: ReadonlyArray<AgentSessionProjectCandidate>,
9 now = Date.now(),
10) {
11 const cutoff = now - RECENT_PROJECT_WINDOW_MS;
12
13 return {
14 available: candidates,
15 recent: candidates.filter((candidate) => {
16 if (candidate.lastActiveAt === null) return false;
17 const lastActiveAt = Date.parse(candidate.lastActiveAt);
18 return lastActiveAt >= cutoff && lastActiveAt <= now;
19 }),
20 };
21}
22
23/** Use the server's project match before the client snapshot, which can lag behind the scan. */
24export function resolveOnboardingProjectId(
25 projects: ReadonlyArray<{
26 readonly id: ProjectId;
27 readonly environmentId: EnvironmentId;
28 readonly workspaceRoot: string;
29 }>,
30 environmentId: EnvironmentId,
31 candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">,
32): ProjectId | null {
33 if (candidate.projectId !== undefined) return candidate.projectId;
34 const environmentProjects = projects.filter((project) => project.environmentId === environmentId);
35 const currentRootMatch = findProjectByPath(environmentProjects, candidate.path);
36 if (currentRootMatch !== undefined) return currentRootMatch.id;
37 return null;
38}
39
40/** Prefer a selected project with imported history, then a completed empty import. */
41export function resolveOnboardingLandingProject<T>(
42 selection: ReadonlyArray<string>,
43 projectsWithImportedHistory: ReadonlyMap<string, T>,
44 completedProjects: ReadonlyMap<string, T>,
45): T | undefined {
46 for (const path of selection) {
47 const project = projectsWithImportedHistory.get(path);
48 if (project !== undefined) return project;
49 }
50 for (const path of selection) {
51 const project = completedProjects.get(path);
52 if (project !== undefined) return project;
53 }
54 return undefined;
55}
apps/web/src/onboarding/providerReadiness.logic.test.ts created+317
...@@ -0,0 +1,317 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";
8
9import {
10 getOnboardingProviderState,
11 resolveOnboardingProviderLoginCommand,
12 selectOnboardingProvidersByDriver,
13} from "./providerReadiness.logic";
14
15const readyCodex: ServerProvider = {
16 instanceId: ProviderInstanceId.make("codex"),
17 driver: ProviderDriverKind.make("codex"),
18 enabled: true,
19 installed: true,
20 version: "1.0.0",
21 status: "ready",
22 auth: { status: "unknown" },
23 checkedAt: "2026-08-23T00:00:00.000Z",
24 models: [],
25 slashCommands: [],
26 skills: [],
27};
28
29describe("getOnboardingProviderState", () => {
30 it("treats an enabled Codex provider with ready status and unknown authentication as ready", () => {
31 expect(getOnboardingProviderState(readyCodex)).toBe("ready");
32 });
33
34 it("treats authenticated providers as ready only when their provider status is ready", () => {
35 expect(getOnboardingProviderState({ ...readyCodex, auth: { status: "authenticated" } })).toBe(
36 "ready",
37 );
38 expect(
39 getOnboardingProviderState({
40 ...readyCodex,
41 auth: { status: "authenticated" },
42 status: "error",
43 }),
44 ).toBe("attention");
45 expect(
46 getOnboardingProviderState({
47 ...readyCodex,
48 auth: { status: "authenticated" },
49 status: "warning",
50 }),
51 ).toBe("attention");
52 });
53
54 it("offers sign-in only when the server reports an authentication failure", () => {
55 expect(
56 getOnboardingProviderState({
57 ...readyCodex,
58 status: "error",
59 auth: { status: "unauthenticated" },
60 }),
61 ).toBe("signIn");
62 expect(getOnboardingProviderState({ ...readyCodex, status: "error" })).toBe("attention");
63 expect(getOnboardingProviderState({ ...readyCodex, status: "warning" })).toBe("attention");
64 });
65
66 it("does not offer installation or sign-in for disabled providers", () => {
67 expect(getOnboardingProviderState({ ...readyCodex, enabled: false, installed: false })).toBe(
68 "disabled",
69 );
70 expect(getOnboardingProviderState({ ...readyCodex, status: "disabled" })).toBe("disabled");
71 });
72
73 it("offers installation only when an enabled provider is missing", () => {
74 expect(getOnboardingProviderState({ ...readyCodex, installed: false, status: "error" })).toBe(
75 "install",
76 );
77 });
78
79 it("waits for a provider snapshot before offering an action", () => {
80 expect(getOnboardingProviderState(undefined)).toBe("checking");
81 });
82});
83
84describe("selectOnboardingProvidersByDriver", () => {
85 it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => {
86 const signedOutCodex: ServerProvider = {
87 ...readyCodex,
88 instanceId: ProviderInstanceId.make("codex_work"),
89 status: "error",
90 auth: { status: "unauthenticated" },
91 };
92
93 expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe(
94 readyCodex,
95 );
96 });
97
98 it("prefers a provider with an actionable sign-in over a failed provider", () => {
99 const failedCodex: ServerProvider = { ...readyCodex, status: "error" };
100 const signedOutCodex: ServerProvider = {
101 ...readyCodex,
102 instanceId: ProviderInstanceId.make("codex_work"),
103 status: "error",
104 auth: { status: "unauthenticated" },
105 };
106
107 expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe(
108 signedOutCodex,
109 );
110 });
111
112 it("prefers installed providers over missing or disabled instances", () => {
113 const disabledCodex: ServerProvider = { ...readyCodex, enabled: false };
114 const missingCodex: ServerProvider = {
115 ...readyCodex,
116 instanceId: ProviderInstanceId.make("codex_work"),
117 installed: false,
118 status: "error",
119 };
120
121 expect(
122 selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"),
123 ).toBe(readyCodex);
124 });
125
126 it("handles provider snapshots that have not arrived", () => {
127 expect(selectOnboardingProvidersByDriver(undefined).size).toBe(0);
128 });
129
130 it("keeps a ready custom account when the default account is signed out", () => {
131 const signedOutDefault: ServerProvider = {
132 ...readyCodex,
133 status: "error",
134 auth: { status: "unauthenticated" },
135 };
136 const readyCustom: ServerProvider = {
137 ...readyCodex,
138 instanceId: ProviderInstanceId.make("codex_work"),
139 };
140
141 expect(selectOnboardingProvidersByDriver([signedOutDefault, readyCustom]).get("codex")).toBe(
142 readyCustom,
143 );
144 });
145});
146
147describe("resolveOnboardingProviderLoginCommand", () => {
148 it("uses the selected Codex account binary", () => {
149 const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") };
150
151 expect(
152 resolveOnboardingProviderLoginCommand(
153 provider,
154 {
155 ...DEFAULT_SERVER_SETTINGS,
156 providerInstances: {
157 [provider.instanceId]: {
158 driver: provider.driver,
159 config: { binaryPath: "/opt/codex-work/bin/codex" },
160 },
161 },
162 },
163 "linux",
164 ),
165 ).toBe("/opt/codex-work/bin/codex login");
166 });
167
168 it("uses the selected Claude account binary", () => {
169 const provider: ServerProvider = {
170 ...readyCodex,
171 driver: ProviderDriverKind.make("claudeAgent"),
172 instanceId: ProviderInstanceId.make("claude_work"),
173 };
174
175 expect(
176 resolveOnboardingProviderLoginCommand(
177 provider,
178 {
179 ...DEFAULT_SERVER_SETTINGS,
180 providerInstances: {
181 [provider.instanceId]: {
182 driver: provider.driver,
183 config: { binaryPath: "/opt/claude-work/bin/claude" },
184 },
185 },
186 },
187 "linux",
188 ),
189 ).toBe("/opt/claude-work/bin/claude auth login");
190 });
191
192 it("quotes a Codex path with spaces for PowerShell", () => {
193 expect(
194 resolveOnboardingProviderLoginCommand(
195 readyCodex,
196 {
197 ...DEFAULT_SERVER_SETTINGS,
198 providers: {
199 ...DEFAULT_SERVER_SETTINGS.providers,
200 codex: {
201 ...DEFAULT_SERVER_SETTINGS.providers.codex,
202 binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe",
203 },
204 },
205 },
206 "windows",
207 ),
208 ).toBe("& 'C:\\Program Files\\Codex & Tools\\codex.exe' login");
209 });
210
211 it("quotes a Claude path with shell metacharacters on POSIX", () => {
212 const provider: ServerProvider = {
213 ...readyCodex,
214 driver: ProviderDriverKind.make("claudeAgent"),
215 instanceId: ProviderInstanceId.make("claude"),
216 };
217
218 expect(
219 resolveOnboardingProviderLoginCommand(
220 provider,
221 {
222 ...DEFAULT_SERVER_SETTINGS,
223 providers: {
224 ...DEFAULT_SERVER_SETTINGS.providers,
225 claudeAgent: {
226 ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
227 binaryPath: "/opt/Claude Tools/$current/claude",
228 },
229 },
230 },
231 "linux",
232 ),
233 ).toBe("'/opt/Claude Tools/$current/claude' auth login");
234 });
235
236 it.each([
237 ["~/my tools/codex", "~/'my tools/codex' login"],
238 ["~\\my tools/codex", "~/'my tools/codex' login"],
239 ["~/tools/codex's build", `~/'tools/codex'"'"'s build' login`],
240 ["~\\tools\\codex's build", `~/'tools\\codex'"'"'s build' login`],
241 ["~/tools/codex; echo unsafe", "~/'tools/codex; echo unsafe' login"],
242 ])("keeps the home prefix expandable while quoting %s", (binaryPath, expectedCommand) => {
243 expect(
244 resolveOnboardingProviderLoginCommand(
245 readyCodex,
246 {
247 ...DEFAULT_SERVER_SETTINGS,
248 providers: {
249 ...DEFAULT_SERVER_SETTINGS.providers,
250 codex: {
251 ...DEFAULT_SERVER_SETTINGS.providers.codex,
252 binaryPath,
253 },
254 },
255 },
256 "linux",
257 ),
258 ).toBe(expectedCommand);
259 });
260
261 it.each(["darwin", "linux"] as const)("quotes backslashes in a Codex path on %s", (platform) => {
262 expect(
263 resolveOnboardingProviderLoginCommand(
264 readyCodex,
265 {
266 ...DEFAULT_SERVER_SETTINGS,
267 providers: {
268 ...DEFAULT_SERVER_SETTINGS.providers,
269 codex: {
270 ...DEFAULT_SERVER_SETTINGS.providers.codex,
271 binaryPath: "/opt/codex\\work/codex",
272 },
273 },
274 },
275 platform,
276 ),
277 ).toBe("'/opt/codex\\work/codex' login");
278 });
279
280 it("keeps a plain Windows path unquoted", () => {
281 expect(
282 resolveOnboardingProviderLoginCommand(
283 readyCodex,
284 {
285 ...DEFAULT_SERVER_SETTINGS,
286 providers: {
287 ...DEFAULT_SERVER_SETTINGS.providers,
288 codex: {
289 ...DEFAULT_SERVER_SETTINGS.providers.codex,
290 binaryPath: "C:\\Tools\\codex.exe",
291 },
292 },
293 },
294 "windows",
295 ),
296 ).toBe("C:\\Tools\\codex.exe login");
297 });
298
299 it("uses the default command when an old server reports an unknown shell", () => {
300 expect(
301 resolveOnboardingProviderLoginCommand(
302 readyCodex,
303 {
304 ...DEFAULT_SERVER_SETTINGS,
305 providers: {
306 ...DEFAULT_SERVER_SETTINGS.providers,
307 codex: {
308 ...DEFAULT_SERVER_SETTINGS.providers.codex,
309 binaryPath: "/opt/Codex Tools/codex",
310 },
311 },
312 },
313 "unknown",
314 ),
315 ).toBe("codex login");
316 });
317});
apps/web/src/onboarding/providerReadiness.logic.ts created+99
...@@ -0,0 +1,99 @@
1import {
2 ClaudeSettings,
3 CodexSettings,
4 type ExecutionEnvironmentPlatformOs,
5 type ServerProvider,
6 type ServerSettings,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10
11const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
12const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
13const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/;
14
15function quoteProviderBinary(
16 binaryPath: string,
17 fallback: string,
18 platform: ExecutionEnvironmentPlatformOs,
19): string {
20 if (
21 SAFE_SHELL_BINARY_PATTERN.test(binaryPath) &&
22 (platform === "windows" || !binaryPath.includes("\\"))
23 ) {
24 return binaryPath;
25 }
26 if (platform === "windows") return `& '${binaryPath.replaceAll("'", "''")}'`;
27 if (platform === "darwin" || platform === "linux") {
28 if (binaryPath.startsWith("~/") || binaryPath.startsWith("~\\")) {
29 return `~/'${binaryPath.slice(2).replaceAll("'", `'"'"'`)}'`;
30 }
31 return `'${binaryPath.replaceAll("'", `'"'"'`)}'`;
32 }
33 return fallback;
34}
35
36export function getOnboardingProviderState(provider: ServerProvider | undefined) {
37 if (provider === undefined) return "checking";
38 if (!provider.enabled || provider.status === "disabled") return "disabled";
39 if (!provider.installed) return "install";
40 if (provider.auth.status === "unauthenticated") return "signIn";
41 if (provider.status === "ready") return "ready";
42 return "attention";
43}
44
45const PROVIDER_STATE_PRIORITY = {
46 checking: 0,
47 disabled: 1,
48 install: 2,
49 attention: 3,
50 signIn: 4,
51 ready: 5,
52} as const;
53
54/** Select the most usable configured instance for each provider driver. */
55export function selectOnboardingProvidersByDriver(
56 providers: ReadonlyArray<ServerProvider> | null | undefined,
57) {
58 const providersByDriver = new Map<string, ServerProvider>();
59
60 for (const provider of providers ?? []) {
61 const existing = providersByDriver.get(provider.driver);
62 if (
63 existing === undefined ||
64 PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] >
65 PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)]
66 ) {
67 providersByDriver.set(provider.driver, provider);
68 }
69 }
70
71 return providersByDriver;
72}
73
74/** Use the selected provider instance's binary when the setup terminal opens its login flow. */
75export function resolveOnboardingProviderLoginCommand(
76 provider: ServerProvider,
77 settings: ServerSettings,
78 platform: ExecutionEnvironmentPlatformOs,
79): string {
80 const instance = settings.providerInstances[provider.instanceId];
81
82 if (provider.driver === "claudeAgent") {
83 const config = decodeClaudeSettings(
84 instance ? (instance.config ?? {}) : settings.providers.claudeAgent,
85 );
86 const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude";
87 return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`;
88 }
89
90 if (provider.driver === "codex") {
91 const config = decodeCodexSettings(
92 instance ? (instance.config ?? {}) : settings.providers.codex,
93 );
94 const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex";
95 return `${quoteProviderBinary(binaryPath, "codex", platform)} login`;
96 }
97
98 return provider.driver;
99}
apps/web/src/onboarding/targetEnvironment.logic.test.ts created+211
...@@ -0,0 +1,211 @@
1import {
2 BearerConnectionTarget,
3 PrimaryConnectionTarget,
4 RelayConnectionTarget,
5 SshConnectionTarget,
6} from "@t3tools/client-runtime/connection";
7import { EnvironmentId } from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 isOnboardingRelayEnvironment,
12 resolveOnboardingTargetEnvironment,
13} from "./targetEnvironment.logic";
14
15const primaryEnvironment = {
16 environmentId: EnvironmentId.make("primary"),
17 connection: { phase: "connected" },
18 entry: {
19 target: new PrimaryConnectionTarget({
20 environmentId: EnvironmentId.make("primary"),
21 label: "This computer",
22 httpBaseUrl: "http://127.0.0.1:3773",
23 wsBaseUrl: "ws://127.0.0.1:3773",
24 }),
25 },
26 label: "This computer",
27} as const;
28
29const olderRelay = {
30 environmentId: EnvironmentId.make("older-remote"),
31 connection: { phase: "connected" },
32 entry: {
33 target: new RelayConnectionTarget({
34 environmentId: EnvironmentId.make("older-remote"),
35 label: "Older computer",
36 }),
37 },
38 label: "Older computer",
39} as const;
40
41const newerRelay = {
42 environmentId: EnvironmentId.make("newer-relay"),
43 connection: { phase: "connected" },
44 entry: {
45 target: new RelayConnectionTarget({
46 environmentId: EnvironmentId.make("newer-relay"),
47 label: "New computer",
48 }),
49 },
50 label: "New computer",
51} as const;
52
53const pairedRemote = {
54 environmentId: EnvironmentId.make("paired-remote"),
55 connection: { phase: "connected" },
56 entry: {
57 target: new BearerConnectionTarget({
58 environmentId: EnvironmentId.make("paired-remote"),
59 label: "Direct computer",
60 connectionId: "paired-remote",
61 }),
62 },
63 label: "Direct computer",
64} as const;
65
66const sshEnvironment = {
67 environmentId: EnvironmentId.make("ssh-remote"),
68 connection: { phase: "connected" },
69 entry: {
70 target: new SshConnectionTarget({
71 environmentId: EnvironmentId.make("ssh-remote"),
72 label: "SSH computer",
73 connectionId: "ssh-remote",
74 }),
75 },
76 label: "SSH computer",
77} as const;
78
79const desktopLocalEnvironment = {
80 environmentId: EnvironmentId.make("desktop-local-wsl"),
81 connection: { phase: "connected" },
82 entry: {
83 target: new BearerConnectionTarget({
84 environmentId: EnvironmentId.make("desktop-local-wsl"),
85 label: "WSL",
86 connectionId: "local:wsl:Ubuntu",
87 }),
88 },
89 label: "WSL",
90} as const;
91
92describe("resolveOnboardingTargetEnvironment", () => {
93 it("waits for the exact paired machine instead of using an older connected machine", () => {
94 const pendingPairedRemote = { ...pairedRemote, connection: { phase: "connecting" } };
95
96 expect(
97 resolveOnboardingTargetEnvironment({
98 mode: "direct",
99 environments: [primaryEnvironment, olderRelay, pendingPairedRemote],
100 primaryEnvironment,
101 pairedEnvironmentId: pairedRemote.environmentId,
102 }),
103 ).toBeNull();
104 });
105
106 it("uses the exact paired machine once it connects", () => {
107 expect(
108 resolveOnboardingTargetEnvironment({
109 mode: "direct",
110 environments: [primaryEnvironment, olderRelay, pairedRemote],
111 primaryEnvironment,
112 pairedEnvironmentId: pairedRemote.environmentId,
113 }),
114 ).toBe(pairedRemote);
115 });
116
117 it("waits for a newly paired machine that has not appeared in the catalog", () => {
118 expect(
119 resolveOnboardingTargetEnvironment({
120 mode: "direct",
121 environments: [primaryEnvironment, olderRelay],
122 primaryEnvironment,
123 pairedEnvironmentId: pairedRemote.environmentId,
124 }),
125 ).toBeNull();
126 });
127
128 it("uses the primary machine for local onboarding", () => {
129 expect(
130 resolveOnboardingTargetEnvironment({
131 mode: "local",
132 environments: [primaryEnvironment, olderRelay],
133 primaryEnvironment,
134 pairedEnvironmentId: null,
135 }),
136 ).toBe(primaryEnvironment);
137 });
138
139 it("does not substitute a remote machine when the local primary is offline", () => {
140 const offlinePrimary = { ...primaryEnvironment, connection: { phase: "disconnected" } };
141
142 expect(
143 resolveOnboardingTargetEnvironment({
144 mode: "local",
145 environments: [offlinePrimary, olderRelay],
146 primaryEnvironment: offlinePrimary,
147 pairedEnvironmentId: null,
148 }),
149 ).toBeNull();
150 });
151
152 it("uses the newest connected remote when no exact machine was selected", () => {
153 expect(
154 resolveOnboardingTargetEnvironment({
155 mode: "connect",
156 environments: [primaryEnvironment, olderRelay, newerRelay],
157 primaryEnvironment,
158 pairedEnvironmentId: null,
159 }),
160 ).toBe(newerRelay);
161 });
162
163 it("ignores direct, SSH, and desktop-managed connections in Connect mode", () => {
164 expect(
165 resolveOnboardingTargetEnvironment({
166 mode: "connect",
167 environments: [
168 primaryEnvironment,
169 olderRelay,
170 pairedRemote,
171 sshEnvironment,
172 desktopLocalEnvironment,
173 ],
174 primaryEnvironment,
175 pairedEnvironmentId: null,
176 }),
177 ).toBe(olderRelay);
178 });
179
180 it("uses the primary computer when no relay connection exists", () => {
181 expect(
182 resolveOnboardingTargetEnvironment({
183 mode: "connect",
184 environments: [primaryEnvironment, pairedRemote, sshEnvironment, desktopLocalEnvironment],
185 primaryEnvironment,
186 pairedEnvironmentId: null,
187 }),
188 ).toBe(primaryEnvironment);
189 });
190
191 it("falls back to the connected primary when no remote is available", () => {
192 expect(
193 resolveOnboardingTargetEnvironment({
194 mode: "connect",
195 environments: [primaryEnvironment],
196 primaryEnvironment,
197 pairedEnvironmentId: null,
198 }),
199 ).toBe(primaryEnvironment);
200 });
201});
202
203describe("isOnboardingRelayEnvironment", () => {
204 it("includes only T3 Connect relay targets", () => {
205 expect(
206 [olderRelay, pairedRemote, sshEnvironment, desktopLocalEnvironment].filter(
207 isOnboardingRelayEnvironment,
208 ),
209 ).toEqual([olderRelay]);
210 });
211});
apps/web/src/onboarding/targetEnvironment.logic.ts created+49
...@@ -0,0 +1,49 @@
1import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
2import type { EnvironmentId } from "@t3tools/contracts";
3
4interface OnboardingEnvironment {
5 readonly environmentId: EnvironmentId;
6 readonly connection: { readonly phase: string };
7 readonly entry: { readonly target: ConnectionTarget };
8}
9
10export function isOnboardingRelayEnvironment(
11 environment: Pick<OnboardingEnvironment, "entry">,
12): boolean {
13 return environment.entry.target._tag === "RelayConnectionTarget";
14}
15
16/** Keep a directly paired machine pinned while its initial connection completes. */
17export function resolveOnboardingTargetEnvironment<TEnvironment extends OnboardingEnvironment>({
18 mode,
19 environments,
20 primaryEnvironment,
21 pairedEnvironmentId,
22}: {
23 readonly mode: "local" | "connect" | "direct";
24 readonly environments: ReadonlyArray<TEnvironment>;
25 readonly primaryEnvironment: TEnvironment | null;
26 readonly pairedEnvironmentId: EnvironmentId | null;
27}): TEnvironment | null {
28 if (mode === "direct" && pairedEnvironmentId !== null) {
29 const pairedEnvironment = environments.find(
30 (environment) => environment.environmentId === pairedEnvironmentId,
31 );
32 return pairedEnvironment?.connection.phase === "connected" ? pairedEnvironment : null;
33 }
34
35 const connectedRelayEnvironments = environments.filter(
36 (environment) =>
37 environment.connection.phase === "connected" && isOnboardingRelayEnvironment(environment),
38 );
39
40 if (mode === "connect" && connectedRelayEnvironments.length > 0) {
41 return connectedRelayEnvironments[connectedRelayEnvironments.length - 1] ?? null;
42 }
43
44 if (primaryEnvironment?.connection.phase === "connected") {
45 return primaryEnvironment;
46 }
47
48 return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null);
49}
apps/web/src/routeTree.gen.ts+21
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as WelcomeRouteImport } from './routes/welcome'
12import { Route as UsageRouteImport } from './routes/usage'13import { Route as UsageRouteImport } from './routes/usage'
13import { Route as SettingsRouteImport } from './routes/settings'14import { Route as SettingsRouteImport } from './routes/settings'
14import { Route as PairRouteImport } from './routes/pair'15import { Route as PairRouteImport } from './routes/pair'
...@@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques...@@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques
30import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'31import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
31import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'32import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
3233
34const WelcomeRoute = WelcomeRouteImport.update({
35 id: '/welcome',
36 path: '/welcome',
37 getParentRoute: () => rootRouteImport,
38} as any)
33const UsageRoute = UsageRouteImport.update({39const UsageRoute = UsageRouteImport.update({
34 id: '/usage',40 id: '/usage',
35 path: '/usage',41 path: '/usage',
...@@ -137,6 +143,7 @@ export interface FileRoutesByFullPath {...@@ -137,6 +143,7 @@ export interface FileRoutesByFullPath {
137 '/pair': typeof PairRoute143 '/pair': typeof PairRoute
138 '/settings': typeof SettingsRouteWithChildren144 '/settings': typeof SettingsRouteWithChildren
139 '/usage': typeof UsageRoute145 '/usage': typeof UsageRoute
146 '/welcome': typeof WelcomeRoute
140 '/pull-requests': typeof ChatPullRequestsRoute147 '/pull-requests': typeof ChatPullRequestsRoute
141 '/connect/callback': typeof ConnectCallbackRoute148 '/connect/callback': typeof ConnectCallbackRoute
142 '/projects/$projectKey': typeof ProjectsProjectKeyRoute149 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
...@@ -157,6 +164,7 @@ export interface FileRoutesByTo {...@@ -157,6 +164,7 @@ export interface FileRoutesByTo {
157 '/pair': typeof PairRoute164 '/pair': typeof PairRoute
158 '/settings': typeof SettingsRouteWithChildren165 '/settings': typeof SettingsRouteWithChildren
159 '/usage': typeof UsageRoute166 '/usage': typeof UsageRoute
167 '/welcome': typeof WelcomeRoute
160 '/pull-requests': typeof ChatPullRequestsRoute168 '/pull-requests': typeof ChatPullRequestsRoute
161 '/connect/callback': typeof ConnectCallbackRoute169 '/connect/callback': typeof ConnectCallbackRoute
162 '/projects/$projectKey': typeof ProjectsProjectKeyRoute170 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
...@@ -180,6 +188,7 @@ export interface FileRoutesById {...@@ -180,6 +188,7 @@ export interface FileRoutesById {
180 '/pair': typeof PairRoute188 '/pair': typeof PairRoute
181 '/settings': typeof SettingsRouteWithChildren189 '/settings': typeof SettingsRouteWithChildren
182 '/usage': typeof UsageRoute190 '/usage': typeof UsageRoute
191 '/welcome': typeof WelcomeRoute
183 '/_chat/pull-requests': typeof ChatPullRequestsRoute192 '/_chat/pull-requests': typeof ChatPullRequestsRoute
184 '/connect_/callback': typeof ConnectCallbackRoute193 '/connect_/callback': typeof ConnectCallbackRoute
185 '/projects/$projectKey': typeof ProjectsProjectKeyRoute194 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
...@@ -204,6 +213,7 @@ export interface FileRouteTypes {...@@ -204,6 +213,7 @@ export interface FileRouteTypes {
204 | '/pair'213 | '/pair'
205 | '/settings'214 | '/settings'
206 | '/usage'215 | '/usage'
216 | '/welcome'
207 | '/pull-requests'217 | '/pull-requests'
208 | '/connect/callback'218 | '/connect/callback'
209 | '/projects/$projectKey'219 | '/projects/$projectKey'
...@@ -224,6 +234,7 @@ export interface FileRouteTypes {...@@ -224,6 +234,7 @@ export interface FileRouteTypes {
224 | '/pair'234 | '/pair'
225 | '/settings'235 | '/settings'
226 | '/usage'236 | '/usage'
237 | '/welcome'
227 | '/pull-requests'238 | '/pull-requests'
228 | '/connect/callback'239 | '/connect/callback'
229 | '/projects/$projectKey'240 | '/projects/$projectKey'
...@@ -246,6 +257,7 @@ export interface FileRouteTypes {...@@ -246,6 +257,7 @@ export interface FileRouteTypes {
246 | '/pair'257 | '/pair'
247 | '/settings'258 | '/settings'
248 | '/usage'259 | '/usage'
260 | '/welcome'
249 | '/_chat/pull-requests'261 | '/_chat/pull-requests'
250 | '/connect_/callback'262 | '/connect_/callback'
251 | '/projects/$projectKey'263 | '/projects/$projectKey'
...@@ -269,12 +281,20 @@ export interface RootRouteChildren {...@@ -269,12 +281,20 @@ export interface RootRouteChildren {
269 PairRoute: typeof PairRoute281 PairRoute: typeof PairRoute
270 SettingsRoute: typeof SettingsRouteWithChildren282 SettingsRoute: typeof SettingsRouteWithChildren
271 UsageRoute: typeof UsageRoute283 UsageRoute: typeof UsageRoute
284 WelcomeRoute: typeof WelcomeRoute
272 ConnectCallbackRoute: typeof ConnectCallbackRoute285 ConnectCallbackRoute: typeof ConnectCallbackRoute
273 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute286 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
274}287}
275288
276declare module '@tanstack/react-router' {289declare module '@tanstack/react-router' {
277 interface FileRoutesByPath {290 interface FileRoutesByPath {
291 '/welcome': {
292 id: '/welcome'
293 path: '/welcome'
294 fullPath: '/welcome'
295 preLoaderRoute: typeof WelcomeRouteImport
296 parentRoute: typeof rootRouteImport
297 }
278 '/usage': {298 '/usage': {
279 id: '/usage'299 id: '/usage'
280 path: '/usage'300 path: '/usage'
...@@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = {...@@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = {
468 PairRoute: PairRoute,488 PairRoute: PairRoute,
469 SettingsRoute: SettingsRouteWithChildren,489 SettingsRoute: SettingsRouteWithChildren,
470 UsageRoute: UsageRoute,490 UsageRoute: UsageRoute,
491 WelcomeRoute: WelcomeRoute,
471 ConnectCallbackRoute: ConnectCallbackRoute,492 ConnectCallbackRoute: ConnectCallbackRoute,
472 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,493 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
473}494}
apps/web/src/routes/__root.tsx+58-16
...@@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic";...@@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic";
16import { AppSidebarLayout } from "../components/AppSidebarLayout";16import { AppSidebarLayout } from "../components/AppSidebarLayout";
17import { CommandPalette } from "../components/CommandPalette";17import { CommandPalette } from "../components/CommandPalette";
18import { ConfirmDialogHost } from "../components/ConfirmDialogHost";18import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
19import { FirstRunGate } from "../components/onboarding/FirstRunGate";
19import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";20import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
20import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";21import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
21import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";22import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
...@@ -97,6 +98,13 @@ function RootRouteView() {...@@ -97,6 +98,13 @@ function RootRouteView() {
97 const pathname = useLocation({ select: (location) => location.pathname });98 const pathname = useLocation({ select: (location) => location.pathname });
98 const { authGateState } = Route.useRouteContext();99 const { authGateState } = Route.useRouteContext();
99 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";100 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
101 const returningFromWelcomeRef = useRef(pathname === "/welcome");
102
103 useEffect(() => {
104 if (pathname === "/welcome") {
105 returningFromWelcomeRef.current = true;
106 }
107 }, [pathname]);
100108
101 useEffect(() => {109 useEffect(() => {
102 const frame = window.requestAnimationFrame(() => {110 const frame = window.requestAnimationFrame(() => {
...@@ -116,6 +124,19 @@ function RootRouteView() {...@@ -116,6 +124,19 @@ function RootRouteView() {
116 );124 );
117 }125 }
118126
127 // The welcome wizard is full-screen like /pair, but keeps toasts so its
128 // connect/import actions can report failures.
129 if (pathname === "/welcome") {
130 return (
131 <ToastProvider>
132 <DocumentTitleSync />
133 <ContrastAppearanceSync />
134 <FontAppearanceSync />
135 <Outlet />
136 </ToastProvider>
137 );
138 }
139
119 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {140 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
120 return (141 return (
121 <>142 <>
...@@ -133,6 +154,10 @@ function RootRouteView() {...@@ -133,6 +154,10 @@ function RootRouteView() {
133 </CommandPalette>154 </CommandPalette>
134 );155 );
135156
157 // FirstRunGate holds back everything below it — including EventRouter,
158 // whose welcome payload navigates into a thread — until the first-run
159 // decision is known, so a fresh install renders nothing (not the shell,
160 // not a flash of threads) before landing on the welcome wizard.
136 return (161 return (
137 <ToastProvider>162 <ToastProvider>
138 <AnchoredToastProvider>163 <AnchoredToastProvider>
...@@ -141,21 +166,28 @@ function RootRouteView() {...@@ -141,21 +166,28 @@ function RootRouteView() {
141 <EnvironmentThemeSync />166 <EnvironmentThemeSync />
142 <GlassAppearanceSync />167 <GlassAppearanceSync />
143 <FontAppearanceSync />168 <FontAppearanceSync />
144 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}169 <FirstRunGate
145 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}170 enabled={primaryEnvironmentAuthenticated}
146 <RelayClientInstallDialog />171 hostedStatic={authGateState.status === "hosted-static"}
147 <ConnectOnboardingDialog />172 >
148 <SshPasswordPromptDialog />173 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
149 <ConfirmDialogHost />174 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
150 <SlowRpcRequestToastCoordinator />175 <RelayClientInstallDialog />
151 <HostedStaticEnvironmentBootstrap />176 <ConnectOnboardingDialog />
152 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}177 <SshPasswordPromptDialog />
153 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}178 <ConfirmDialogHost />
154 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}179 <SlowRpcRequestToastCoordinator />
155 {appShell}180 <HostedStaticEnvironmentBootstrap />
156 {/* Above the router: a theme draft is judged by walking the app, so the181 {primaryEnvironmentAuthenticated ? (
157 editor has to survive navigation away from settings. */}182 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
158 <ThemeEditorHost />183 ) : null}
184 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}
185 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
186 {appShell}
187 {/* Above the router: a theme draft is judged by walking the app, so the
188 editor has to survive navigation away from settings. */}
189 <ThemeEditorHost />
190 </FirstRunGate>
159 </AnchoredToastProvider>191 </AnchoredToastProvider>
160 </ToastProvider>192 </ToastProvider>
161 );193 );
...@@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() {...@@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() {
381 return null;413 return null;
382}414}
383415
384function EventRouter() {416function EventRouter({
417 skipInitialBootstrapNavigation,
418}: {
419 readonly skipInitialBootstrapNavigation: boolean;
420}) {
385 const navigate = useNavigate();421 const navigate = useNavigate();
386 const pathname = useLocation({ select: (loc) => loc.pathname });422 const pathname = useLocation({ select: (loc) => loc.pathname });
387 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);423 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
...@@ -394,6 +430,7 @@ function EventRouter() {...@@ -394,6 +430,7 @@ function EventRouter() {
394 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);430 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
395 const readPathname = useEffectEvent(() => pathname);431 const readPathname = useEffectEvent(() => pathname);
396 const handledBootstrapThreadIdRef = useRef<string | null>(null);432 const handledBootstrapThreadIdRef = useRef<string | null>(null);
433 const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation);
397 const handledConfigEventRef = useRef(serverConfigEvent);434 const handledConfigEventRef = useRef(serverConfigEvent);
398 const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() =>435 const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() =>
399 createKeybindingsUpdateToastController({}),436 createKeybindingsUpdateToastController({}),
...@@ -425,6 +462,11 @@ function EventRouter() {...@@ -425,6 +462,11 @@ function EventRouter() {
425 if (readPathname() !== "/") {462 if (readPathname() !== "/") {
426 return;463 return;
427 }464 }
465 if (skipInitialBootstrapNavigationRef.current) {
466 skipInitialBootstrapNavigationRef.current = false;
467 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
468 return;
469 }
428 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {470 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
429 return;471 return;
430 }472 }
apps/web/src/routes/_chat.index.tsx+6-3
...@@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig";...@@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig";
2121
22function ChatIndexRouteView() {22function ChatIndexRouteView() {
23 const { authGateState } = Route.useRouteContext();23 const { authGateState } = Route.useRouteContext();
24 const { environments } = useEnvironments();24 const { environments, isReady } = useEnvironments();
2525
26 if (authGateState.status === "hosted-static" && environments.length === 0) {26 if (authGateState.status === "hosted-static") {
27 return <HostedStaticOnboardingState />;27 if (!isReady) return null;
28 if (environments.length === 0) return <HostedStaticOnboardingState />;
28 }29 }
2930
30 return <IndexDraftLanding />;31 return <IndexDraftLanding />;
...@@ -79,6 +80,8 @@ function IndexDraftLanding() {...@@ -79,6 +80,8 @@ function IndexDraftLanding() {
79 />80 />
80 ) : null;81 ) : null;
81 }82 }
83 // First-run routing to the welcome wizard happens in FirstRunGate at the
84 // root, before this route ever renders.
82 return <NoProjectsHero />;85 return <NoProjectsHero />;
83}86}
8487
apps/web/src/routes/welcome.tsx created+45
...@@ -0,0 +1,45 @@
1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
2
3import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";
4import { useNewThreadHandler } from "../hooks/useHandleNewThread";
5
6/**
7 * First-run welcome wizard. Full-screen, outside the sidebar shell (the root
8 * route mounts this path bare, like /pair). Reached only via the first-run
9 * gate on the index route; visiting it directly after onboarding is harmless —
10 * finishing again just refreshes the completion flag.
11 */
12export const Route = createFileRoute("/welcome")({
13 beforeLoad: ({ context }) => {
14 const { authGateState } = context;
15 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
16 throw redirect({ to: "/pair", replace: true });
17 }
18 },
19 component: WelcomeRouteView,
20});
21
22function WelcomeRouteView() {
23 const { authGateState } = Route.useRouteContext();
24 const navigate = useNavigate();
25 const openNewThread = useNewThreadHandler();
26 // An authenticated gate means a primary server is serving this app —
27 // desktop, `npx t3`, or a dev server — and that server is "this machine"
28 // no matter what hostname the browser used. Only hosted-static has no
29 // local server to offer.
30 const localAvailable = authGateState.status === "authenticated";
31 return (
32 <WelcomeWizard
33 localAvailable={localAvailable}
34 onDone={(projectRef) => {
35 if (projectRef !== undefined) {
36 void openNewThread(projectRef, { replace: true }).catch(() => {
37 void navigate({ to: "/", replace: true });
38 });
39 return;
40 }
41 void navigate({ to: "/", replace: true });
42 }}
43 />
44 );
45}
apps/web/src/state/agentSessions.ts created+25
...@@ -0,0 +1,25 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import {
3 createEnvironmentRpcCommand,
4 createEnvironmentRpcQueryAtomFamily,
5} from "@t3tools/client-runtime/state/runtime";
6
7import { connectionAtomRuntime } from "../connection/runtime";
8
9/**
10 * Scan of Claude Code / Codex home directories on an environment, surfacing
11 * project candidates for the welcome wizard's import step. The scan walks the
12 * filesystem server-side, so results are cached briefly and refreshed when the
13 * import step remounts.
14 */
15export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, {
16 label: "environment-data:agent-sessions:scan",
17 tag: WS_METHODS.agentSessionsScan,
18 staleTimeMs: 30_000,
19 idleTtlMs: 5 * 60_000,
20});
21
22export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRuntime, {
23 label: "environment-data:agent-sessions:import",
24 tag: WS_METHODS.agentSessionsImport,
25});
docs/user/welcome-wizard.md created+62
...@@ -0,0 +1,62 @@
1# Welcome wizard
2
3T3 Code shows a setup flow when you open a new installation or connect to the
4hosted app for the first time. Existing workspaces skip this flow.
5
6## Choose a connection
7
8- **This computer** runs agents on the computer that hosts T3 Code. It does not
9 require an account.
10- **T3 Connect** connects computers that are signed in to your account. Run
11 `npx t3 connect` on each computer you want to add, then start T3 Code or run
12 `npx t3 serve` so the computer stays available.
13- **Pair a server** connects directly to a server on your network or tailnet.
14 Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and
15 paste the pairing link. You can also run `npx t3 serve --host <address>` and
16 use `npx t3 pair` when the server is already reachable on your network.
17
18If T3 Code cannot confirm the workspace during startup, the setup flow shows
19**Still connecting** instead of opening the app. Select **Reload** to try again.
20
21If T3 Code cannot read your saved settings, it shows **Could not read settings**.
22Select **Retry** after storage becomes available. Setup does not replace
23unreadable settings with defaults.
24
25## Check your agents
26
27T3 Code checks the selected computer for Claude Code and Codex. If an agent is
28not installed or signed in, select its action to open a terminal with the
29correct command ready to run. Other providers can be enabled in Settings.
30
31The setup terminal uses the home directory and environment configured for the
32selected provider instance. Sensitive values remain redacted in Settings and
33terminal metadata while the terminal process can use them.
34
35## Import your projects
36
37T3 Code finds directories that Claude Code or Codex has used. The default
38selection includes projects active within the last 30 days. Select **Choose**
39to include older projects or change the selection.
40
41A large or malformed history can reach the scan limit. T3 Code keeps the
42projects it found and warns when projects or conversations may be missing.
43
44Imported projects include Codex and Claude conversations active within the last
4530 days. You can continue those conversations in T3 Code.
46
47Conversation import is best effort. T3 Code keeps the first user prompt and the
48newest remaining visible user and assistant messages, with 200 messages total.
49It omits tool activity and attachments. For Codex, it omits generated setup
50context only when a canonical user event and a valid shared turn ID identify the
51same user turn. Ambiguous legacy or response-only context stays in the imported
52conversation so T3 Code does not remove user text. It reads one conversation at
53a time and skips files larger than 16 MiB. It ignores malformed records and skips
54unreadable or unparseable conversations.
55
56Each import attempt reads up to 100 conversation files and 64 MiB per project,
57with up to 100,000 input records. Run import again to continue a large batch.
58Completed conversations are not imported again. You can continue without the
59remaining history.
60
61You can skip agent setup and project import. Select **Back** to return to a
62previous step.
packages/client-runtime/src/rpc/client.test.ts+75-1
...@@ -3,10 +3,12 @@ import {...@@ -3,10 +3,12 @@ import {
3 EnvironmentId,3 EnvironmentId,
4 type RelayClientInstallProgressEvent,4 type RelayClientInstallProgressEvent,
5 type ServerConfigStreamEvent,5 type ServerConfigStreamEvent,
6 type ServerLifecycleStreamEvent,
6 WS_METHODS,7 WS_METHODS,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";9import { describe, expect, it } from "@effect/vitest";
9import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
11import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
...@@ -27,7 +29,13 @@ import {...@@ -27,7 +29,13 @@ import {
27import * as EnvironmentSupervisor from "../connection/supervisor.ts";29import * as EnvironmentSupervisor from "../connection/supervisor.ts";
28import * as RpcSession from "../rpc/session.ts";30import * as RpcSession from "../rpc/session.ts";
29import type { WsRpcProtocolClient } from "../rpc/protocol.ts";31import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
30import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts";32import {
33 EnvironmentRpcRequestObserver,
34 request,
35 runStream,
36 subscribe,
37 subscribeDynamicWithSession,
38} from "./client.ts";
3139
32const TARGET = new PrimaryConnectionTarget({40const TARGET = new PrimaryConnectionTarget({
33 environmentId: EnvironmentId.make("environment-1"),41 environmentId: EnvironmentId.make("environment-1"),
...@@ -221,6 +229,72 @@ describe("environment RPC", () => {...@@ -221,6 +229,72 @@ describe("environment RPC", () => {
221 }),229 }),
222 );230 );
223231
232 it.effect("keeps the producer session on an old value buffered across a session switch", () =>
233 Effect.gen(function* () {
234 const firstSubscribed = yield* Deferred.make<void>();
235 const secondSubscribed = yield* Deferred.make<void>();
236 const firstValueBlocked = yield* Deferred.make<void>();
237 const releaseFirstValue = yield* Deferred.make<void>();
238 const firstValue = { source: "first", index: 1 } as unknown as ServerLifecycleStreamEvent;
239 const bufferedFirstValue = {
240 source: "first",
241 index: 2,
242 } as unknown as ServerLifecycleStreamEvent;
243 const secondValue = { source: "second", index: 1 } as unknown as ServerLifecycleStreamEvent;
244 const firstClient = {
245 [WS_METHODS.subscribeServerLifecycle]: () =>
246 Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(
247 Stream.drain,
248 Stream.concat(Stream.fromIterable([firstValue, bufferedFirstValue])),
249 Stream.concat(Stream.never),
250 ),
251 } as unknown as WsRpcProtocolClient;
252 const secondClient = {
253 [WS_METHODS.subscribeServerLifecycle]: () =>
254 Stream.fromEffect(Deferred.succeed(secondSubscribed, undefined)).pipe(
255 Stream.drain,
256 Stream.concat(Stream.make(secondValue)),
257 Stream.concat(Stream.never),
258 ),
259 } as unknown as WsRpcProtocolClient;
260 const firstSession = session(firstClient);
261 const secondSession = session(secondClient);
262 const { activeSession, supervisor } = yield* makeHarness();
263
264 const resultFiber = yield* subscribeDynamicWithSession(
265 WS_METHODS.subscribeServerLifecycle,
266 () => Effect.succeed({}),
267 ).pipe(
268 Stream.mapEffect(([producerSession, value]) =>
269 value === firstValue
270 ? Deferred.succeed(firstValueBlocked, undefined).pipe(
271 Effect.andThen(Deferred.await(releaseFirstValue)),
272 Effect.as([producerSession, value] as const),
273 )
274 : Effect.succeed([producerSession, value] as const),
275 ),
276 Stream.take(3),
277 Stream.runCollect,
278 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
279 Effect.forkChild,
280 );
281
282 yield* SubscriptionRef.set(activeSession, Option.some(firstSession));
283 yield* Deferred.await(firstSubscribed);
284 yield* Deferred.await(firstValueBlocked);
285 yield* SubscriptionRef.set(activeSession, Option.some(secondSession));
286 yield* Deferred.await(secondSubscribed);
287 yield* Deferred.succeed(releaseFirstValue, undefined);
288
289 const result = yield* Fiber.join(resultFiber);
290 expect(result).toEqual([
291 [firstSession, firstValue],
292 [firstSession, bufferedFirstValue],
293 [secondSession, secondValue],
294 ]);
295 }),
296 );
297
224 it.effect("keeps durable subscriptions alive across a transport failure and new session", () =>298 it.effect("keeps durable subscriptions alive across a transport failure and new session", () =>
225 Effect.gen(function* () {299 Effect.gen(function* () {
226 const subscriptions: string[] = [];300 const subscriptions: string[] = [];
packages/client-runtime/src/rpc/client.ts+38-11
...@@ -178,15 +178,15 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {...@@ -178,15 +178,15 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {
178 readonly resubscribe?: Stream.Stream<unknown, never, never>;178 readonly resubscribe?: Stream.Stream<unknown, never, never>;
179}179}
180180
181export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(181function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
182 tag: TTag,182 tag: TTag,
183 makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>,183 makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>,
184 mapStream: (
185 session: RpcSession,
186 stream: Stream.Stream<EnvironmentRpcStreamValue<TTag>, EnvironmentRpcStreamFailure<TTag>>,
187 ) => Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>>,
184 options?: SubscriptionOptions<TTag>,188 options?: SubscriptionOptions<TTag>,
185): Stream.Stream<189): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>, EnvironmentSupervisor> {
186 EnvironmentRpcStreamValue<TTag>,
187 EnvironmentRpcStreamFailure<TTag>,
188 EnvironmentSupervisor
189> {
190 return Stream.unwrap(190 return Stream.unwrap(
191 Effect.gen(function* () {191 Effect.gen(function* () {
192 const supervisor = yield* EnvironmentSupervisor;192 const supervisor = yield* EnvironmentSupervisor;
...@@ -216,10 +216,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(...@@ -216,10 +216,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
216 EnvironmentRpcStreamValue<TTag>,216 EnvironmentRpcStreamValue<TTag>,
217 EnvironmentRpcStreamFailure<TTag>217 EnvironmentRpcStreamFailure<TTag>
218 >;218 >;
219 const subscribeToSession = (): Stream.Stream<219 const subscribeToSession = (): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>> =>
220 EnvironmentRpcStreamValue<TTag>,
221 EnvironmentRpcStreamFailure<TTag>
222 > =>
223 Stream.suspend(() =>220 Stream.suspend(() =>
224 Stream.unwrap(221 Stream.unwrap(
225 Effect.gen(function* () {222 Effect.gen(function* () {
...@@ -229,7 +226,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(...@@ -229,7 +226,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
229 method: tag,226 method: tag,
230 input,227 input,
231 });228 });
232 return method(input).pipe(229 return mapStream(session, method(input)).pipe(
233 Stream.ensuring(completeObservation),230 Stream.ensuring(completeObservation),
234 Stream.catchCause((cause) => {231 Stream.catchCause((cause) => {
235 const hasOnlyExpectedFailures =232 const hasOnlyExpectedFailures =
...@@ -287,6 +284,36 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(...@@ -287,6 +284,36 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
287 );284 );
288}285}
289286
287export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
288 tag: TTag,
289 makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>,
290 options?: SubscriptionOptions<TTag>,
291): Stream.Stream<
292 EnvironmentRpcStreamValue<TTag>,
293 EnvironmentRpcStreamFailure<TTag>,
294 EnvironmentSupervisor
295> {
296 return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options);
297}
298
299/** Tags each value before `switchMap` can buffer it across a session change. */
300export function subscribeDynamicWithSession<TTag extends EnvironmentSubscriptionRpcTag>(
301 tag: TTag,
302 makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>,
303 options?: SubscriptionOptions<TTag>,
304): Stream.Stream<
305 readonly [session: RpcSession, value: EnvironmentRpcStreamValue<TTag>],
306 EnvironmentRpcStreamFailure<TTag>,
307 EnvironmentSupervisor
308> {
309 return subscribeDynamicMapped(
310 tag,
311 makeInput,
312 (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)),
313 options,
314 );
315}
316
290export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(317export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
291 tag: TTag,318 tag: TTag,
292 input: EnvironmentRpcInput<TTag>,319 input: EnvironmentRpcInput<TTag>,
packages/client-runtime/src/state/server.test.ts+211-7
...@@ -7,8 +7,8 @@ import {...@@ -7,8 +7,8 @@ import {
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
9import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
10import * as Duration from "effect/Duration";
11import * as Deferred from "effect/Deferred";10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
...@@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts";...@@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts";
31import type { WsRpcProtocolClient } from "../rpc/protocol.ts";31import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
32import type { RpcSession } from "../rpc/session.ts";32import type { RpcSession } from "../rpc/session.ts";
33import {33import {
34 applyServerWelcomeEvent,
35 makeEnvironmentServerWelcomeState,
34 makeEnvironmentServerConfigState,36 makeEnvironmentServerConfigState,
35 isLegacyUpdateHandoffLoss,37 isLegacyUpdateHandoffLoss,
36 matchesServerUpdateReadyEvent,38 matchesServerUpdateReadyEvent,
37 matchesServerUpdateResumeEvent,39 matchesServerUpdateResumeEvent,
38 nudgeReconnectDuringUpdateRestart,40 nudgeReconnectDuringUpdateRestart,
39 projectServerWelcome,
40 resolveServerConfigValue,41 resolveServerConfigValue,
42 resolveServerWelcomeState,
41 resolveServerUpdateProgressResult,43 resolveServerUpdateProgressResult,
42 serverUpdateStateForProgressEvent,44 serverUpdateStateForProgressEvent,
43 serverUpdateStateForServerVersion,45 serverUpdateStateForServerVersion,
...@@ -494,25 +496,227 @@ describe("server state projection", () => {...@@ -494,25 +496,227 @@ describe("server state projection", () => {
494 expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined();496 expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined();
495 });497 });
496498
497 it("retains welcome when a ready event follows in the same stream chunk", () => {499 it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => {
500 const firstSession = session({} as WsRpcProtocolClient);
501 const secondSession = session({} as WsRpcProtocolClient);
498 const welcome = {502 const welcome = {
499 environment: {} as ServerLifecycleWelcomePayload["environment"],503 environment: {} as ServerLifecycleWelcomePayload["environment"],
500 cwd: "/repo",504 cwd: "/repo",
501 projectName: "repo",505 projectName: "repo",
502 } as ServerLifecycleWelcomePayload;506 } as ServerLifecycleWelcomePayload;
503 const [afterWelcome] = projectServerWelcome(Option.none(), {507 const initial = {
508 currentSession: firstSession,
509 welcomeSession: firstSession,
510 welcome: null,
511 };
512 const afterWelcome = applyServerWelcomeEvent(initial, firstSession, {
504 type: "welcome",513 type: "welcome",
505 payload: welcome,514 payload: welcome,
506 });515 });
507 const [afterReady, emitted] = projectServerWelcome(afterWelcome, {516 const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, {
508 type: "ready",517 type: "ready",
509 payload: {},518 payload: {},
510 });519 });
520 const afterSwitch = { ...afterReady, currentSession: secondSession };
521 const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, {
522 type: "welcome",
523 payload: { ...welcome, cwd: "/stale" },
524 });
511525
512 expect(Option.getOrThrow(afterReady)).toBe(welcome);526 expect(afterReady).toBe(afterWelcome);
513 expect(emitted).toEqual([]);527 expect(resolveServerWelcomeState(afterReady)).toBe(welcome);
528 expect(afterBufferedOldWelcome).toBe(afterSwitch);
529 expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull();
514 });530 });
515531
532 it.effect("checks the authoritative session before accepting a buffered welcome", () =>
533 Effect.gen(function* () {
534 const firstEvents = yield* Queue.unbounded<{
535 readonly type: "welcome" | "ready";
536 readonly payload: unknown;
537 }>();
538 const firstSubscribed = yield* Deferred.make<void>();
539 const firstClient = {
540 [WS_METHODS.subscribeServerLifecycle]: () =>
541 Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(
542 Stream.drain,
543 Stream.concat(Stream.fromQueue(firstEvents)),
544 ),
545 } as unknown as WsRpcProtocolClient;
546 const firstSession = session(firstClient);
547 const secondSession = session({} as WsRpcProtocolClient);
548 const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession));
549 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
550 target: TARGET,
551 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
552 session: supervisorSession,
553 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
554 connect: Effect.void,
555 disconnect: Effect.void,
556 retryNow: Effect.void,
557 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
558 const staleWelcome = {
559 environment: {} as ServerLifecycleWelcomePayload["environment"],
560 cwd: "/stale",
561 projectName: "stale",
562 } as ServerLifecycleWelcomePayload;
563
564 yield* Effect.scoped(
565 Effect.gen(function* () {
566 const state = yield* makeEnvironmentServerWelcomeState().pipe(
567 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
568 );
569 yield* Deferred.await(firstSubscribed);
570
571 // Model the point after the ref changed but before either subscriber
572 // processed its publication.
573 supervisorSession.value = Option.some(secondSession);
574 const handled = yield* SubscriptionRef.changes(state).pipe(
575 Stream.filter(
576 (value) => value.currentSession === secondSession || value.welcome === staleWelcome,
577 ),
578 Stream.runHead,
579 Effect.map(Option.getOrThrow),
580 Effect.forkChild,
581 );
582 yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome });
583
584 const next = yield* Fiber.join(handled);
585 expect(next.currentSession).toBe(secondSession);
586 expect(resolveServerWelcomeState(next)).toBeNull();
587 }),
588 );
589 }),
590 );
591
592 it.effect("reads the authoritative session after waiting for the welcome state lock", () =>
593 Effect.gen(function* () {
594 const firstSubscribed = yield* Deferred.make<void>();
595 const firstClient = {
596 [WS_METHODS.subscribeServerLifecycle]: () =>
597 Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain),
598 } as unknown as WsRpcProtocolClient;
599 const secondClient = {
600 [WS_METHODS.subscribeServerLifecycle]: () => Stream.never,
601 } as unknown as WsRpcProtocolClient;
602 const firstSession = session(firstClient);
603 const secondSession = session(secondClient);
604 const thirdSession = session({} as WsRpcProtocolClient);
605 const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession));
606 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
607 target: TARGET,
608 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
609 session: supervisorSession,
610 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
611 connect: Effect.void,
612 disconnect: Effect.void,
613 retryNow: Effect.void,
614 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
615
616 yield* Effect.scoped(
617 Effect.gen(function* () {
618 const state = yield* makeEnvironmentServerWelcomeState().pipe(
619 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
620 );
621 yield* Deferred.await(firstSubscribed);
622 const changed = yield* SubscriptionRef.changes(state).pipe(
623 Stream.filter((value) => value.currentSession !== firstSession),
624 Stream.runHead,
625 Effect.map(Option.getOrThrow),
626 Effect.forkChild,
627 );
628
629 yield* state.semaphore.withPermit(
630 Effect.gen(function* () {
631 yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession));
632 yield* Effect.yieldNow;
633 yield* Effect.yieldNow;
634 yield* Effect.yieldNow;
635 supervisorSession.value = Option.some(thirdSession);
636 }),
637 );
638
639 expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession);
640 }),
641 );
642 }),
643 );
644
645 it.effect("clears a welcome until the reconnected session sends its own", () =>
646 Effect.gen(function* () {
647 const firstEvents = yield* Queue.unbounded<{
648 readonly type: "welcome" | "ready";
649 readonly payload: unknown;
650 }>();
651 const secondEvents = yield* Queue.unbounded<{
652 readonly type: "welcome" | "ready";
653 readonly payload: unknown;
654 }>();
655 const firstClient = {
656 [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents),
657 } as unknown as WsRpcProtocolClient;
658 const secondClient = {
659 [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents),
660 } as unknown as WsRpcProtocolClient;
661 const firstSession = session(firstClient);
662 const secondSession = session(secondClient);
663 const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession));
664 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
665 target: TARGET,
666 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
667 session: supervisorSession,
668 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
669 connect: Effect.void,
670 disconnect: Effect.void,
671 retryNow: Effect.void,
672 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
673 const firstWelcome = {
674 environment: {} as ServerLifecycleWelcomePayload["environment"],
675 cwd: "/first",
676 projectName: "first",
677 } as ServerLifecycleWelcomePayload;
678 const secondWelcome = {
679 environment: {} as ServerLifecycleWelcomePayload["environment"],
680 cwd: "/second",
681 projectName: "second",
682 } as ServerLifecycleWelcomePayload;
683
684 yield* Effect.scoped(
685 Effect.gen(function* () {
686 const state = yield* makeEnvironmentServerWelcomeState().pipe(
687 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
688 );
689 const nextResolved = (
690 predicate: (value: ServerLifecycleWelcomePayload | null) => boolean,
691 ) =>
692 SubscriptionRef.changes(state).pipe(
693 Stream.map(resolveServerWelcomeState),
694 Stream.filter(predicate),
695 Stream.runHead,
696 Effect.map(Option.getOrThrow),
697 );
698
699 const first = yield* nextResolved((value) => value === firstWelcome).pipe(
700 Effect.forkChild,
701 );
702 yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome });
703 expect(yield* Fiber.join(first)).toBe(firstWelcome);
704
705 const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild);
706 yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession));
707 expect(yield* Fiber.join(cleared)).toBeNull();
708 expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull();
709
710 const second = yield* nextResolved((value) => value === secondWelcome).pipe(
711 Effect.forkChild,
712 );
713 yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome });
714 expect(yield* Fiber.join(second)).toBe(secondWelcome);
715 }),
716 );
717 }),
718 );
719
516 it("prefers an active session config over cache until a live event arrives", () => {720 it("prefers an active session config over cache until a live event arrives", () => {
517 const config = (source: string, serverVersion: string) =>721 const config = (source: string, serverVersion: string) =>
518 ({722 ({
packages/client-runtime/src/state/server.ts+133-19
...@@ -40,8 +40,10 @@ import {...@@ -40,8 +40,10 @@ import {
40 request,40 request,
41 runStream,41 runStream,
42 subscribe,42 subscribe,
43 subscribeDynamicWithSession,
43 type EnvironmentRpcInput,44 type EnvironmentRpcInput,
44} from "../rpc/client.ts";45} from "../rpc/client.ts";
46import type { RpcSession } from "../rpc/session.ts";
45import { followStreamInEnvironment } from "./runtime.ts";47import { followStreamInEnvironment } from "./runtime.ts";
46import {48import {
47 applyServerConfigProjection,49 applyServerConfigProjection,
...@@ -476,21 +478,119 @@ export function serverConfigStateChanges(...@@ -476,21 +478,119 @@ export function serverConfigStateChanges(
476 );478 );
477}479}
478480
479export function projectServerWelcome(481export function applyServerWelcomeEvent(
480 current: Option.Option<ServerLifecycleWelcomePayload>,482 current: EnvironmentServerWelcomeState,
483 session: RpcSession,
481 event: {484 event: {
482 readonly type: "welcome" | "ready";485 readonly type: "welcome" | "ready";
483 readonly payload: unknown;486 readonly payload: unknown;
484 },487 },
485): readonly [488): EnvironmentServerWelcomeState {
486 Option.Option<ServerLifecycleWelcomePayload>,489 return event.type === "welcome" && current.currentSession === session
487 ReadonlyArray<ServerLifecycleWelcomePayload>,490 ? {
488] {491 ...current,
489 if (event.type !== "welcome") {492 welcomeSession: session,
490 return [current, []];493 welcome: event.payload as ServerLifecycleWelcomePayload,
491 }494 }
492 const welcome = event.payload as ServerLifecycleWelcomePayload;495 : current;
493 return [Option.some(welcome), [welcome]];496}
497
498export interface EnvironmentServerWelcomeState {
499 readonly currentSession: RpcSession | null;
500 readonly welcomeSession: RpcSession | null;
501 readonly welcome: ServerLifecycleWelcomePayload | null;
502}
503
504export function resolveServerWelcomeState(
505 state: EnvironmentServerWelcomeState,
506): ServerLifecycleWelcomePayload | null {
507 return state.currentSession === state.welcomeSession ? state.welcome : null;
508}
509
510export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")(
511 function* () {
512 const supervisor = yield* EnvironmentSupervisor;
513 const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session));
514 const state = yield* SubscriptionRef.make<EnvironmentServerWelcomeState>({
515 currentSession: initialSession,
516 welcomeSession: null,
517 welcome: null,
518 });
519
520 const updateWithCurrentSession = Effect.fn(
521 "EnvironmentServerWelcomeState.updateWithCurrentSession",
522 )(function* (
523 update: (
524 current: EnvironmentServerWelcomeState,
525 currentSession: RpcSession | null,
526 ) => EnvironmentServerWelcomeState,
527 ) {
528 return yield* SubscriptionRef.modifyEffect(state, (current) =>
529 SubscriptionRef.get(supervisor.session).pipe(
530 Effect.map(
531 (latestSession) =>
532 [undefined, update(current, Option.getOrNull(latestSession))] as const,
533 ),
534 ),
535 );
536 });
537
538 yield* SubscriptionRef.changes(supervisor.session).pipe(
539 Stream.runForEach(() =>
540 updateWithCurrentSession((current, currentSession) => ({
541 ...current,
542 currentSession,
543 })),
544 ),
545 Effect.forkScoped,
546 );
547
548 yield* subscribeDynamicWithSession(
549 WS_METHODS.subscribeServerLifecycle,
550 Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) {
551 yield* updateWithCurrentSession((current, currentSession) =>
552 currentSession === session
553 ? {
554 ...current,
555 currentSession,
556 welcomeSession: session,
557 welcome: null,
558 }
559 : { ...current, currentSession },
560 );
561 return {};
562 }),
563 ).pipe(
564 Stream.runForEach(([session, event]) =>
565 updateWithCurrentSession((current, currentSession) =>
566 applyServerWelcomeEvent(
567 {
568 ...current,
569 currentSession,
570 },
571 session,
572 event,
573 ),
574 ),
575 ),
576 Effect.forkScoped,
577 );
578
579 return state;
580 },
581);
582
583export function serverWelcomeStateChanges(environmentId: EnvironmentId) {
584 return followStreamInEnvironment(
585 environmentId,
586 Stream.unwrap(
587 makeEnvironmentServerWelcomeState().pipe(
588 Effect.map((state) =>
589 SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)),
590 ),
591 ),
592 ),
593 );
494}594}
495595
496export function resolveServerConfigValue(596export function resolveServerConfigValue(
...@@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms<R, E>(
833 Atom.withLabel(`environment-data:server:providers:${environmentId}`),933 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
834 ),934 ),
835 );935 );
936 const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) =>
937 runtime
938 .atom(serverWelcomeStateChanges(environmentId), { initialValue: null })
939 .pipe(
940 Atom.setIdleTTL(5 * 60_000),
941 Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`),
942 ),
943 );
944 const welcomeFamily = Atom.family((environmentId: EnvironmentId) =>
945 Atom.make((get) => {
946 const result = get(welcomeStateFamily(environmentId));
947 if (result._tag !== "Success") return result;
948 return result.value === null
949 ? AsyncResult.initial<ServerLifecycleWelcomePayload, never>(result.waiting)
950 : AsyncResult.success(result.value, result);
951 }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)),
952 );
953 const welcome = (target: {
954 readonly environmentId: EnvironmentId;
955 readonly input: EnvironmentRpcInput<typeof WS_METHODS.subscribeServerLifecycle>;
956 }) => welcomeFamily(target.environmentId);
836957
837 return {958 return {
838 configValueAtom,959 configValueAtom,
...@@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms<R, E>(
916 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),1037 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),
917 }),1038 }),
918 configProjection,1039 configProjection,
919 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {1040 welcome,
920 label: "environment-data:server:welcome",
921 tag: WS_METHODS.subscribeServerLifecycle,
922 transform: (stream) =>
923 stream.pipe(
924 Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome),
925 ),
926 }),
927 consumeResetCredit: createEnvironmentRpcCommand(runtime, {1041 consumeResetCredit: createEnvironmentRpcCommand(runtime, {
928 label: "environment-data:server:consume-reset-credit",1042 label: "environment-data:server:consume-reset-credit",
929 tag: WS_METHODS.providerConsumeResetCredit,1043 tag: WS_METHODS.providerConsumeResetCredit,
packages/client-runtime/src/state/threadReducer.test.ts+128
...@@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => {...@@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => {
380 }380 }
381 });381 });
382382
383 it("keeps imported replies turnless when delivered again", () => {
384 const event = {
385 ...baseEventFields,
386 sequence: 6,
387 occurredAt: "2026-04-01T06:00:00.000Z",
388 aggregateKind: "thread",
389 aggregateId: baseThread.id,
390 type: "thread.message-sent",
391 payload: {
392 threadId: baseThread.id,
393 messageId: MessageId.make("import:codex:session-1:000001"),
394 role: "assistant",
395 text: "Imported reply",
396 turnId: null,
397 streaming: false,
398 createdAt: "2026-03-01T06:00:00.000Z",
399 updatedAt: "2026-03-01T06:00:00.000Z",
400 },
401 } as const;
402
403 const imported = applyThreadDetailEvent(baseThread, event);
404 expect(imported.kind).toBe("updated");
405 if (imported.kind !== "updated") return;
406 expect(imported.thread.latestTurn).toBeNull();
407 expect(imported.thread.checkpoints).toBe(baseThread.checkpoints);
408
409 const repeated = applyThreadDetailEvent(imported.thread, { ...event, sequence: 7 });
410 expect(repeated.kind).toBe("updated");
411 if (repeated.kind !== "updated") return;
412 expect(repeated.thread.messages).toEqual(imported.thread.messages);
413 expect(repeated.thread.latestTurn).toBeNull();
414 expect(repeated.thread.checkpoints).toBe(baseThread.checkpoints);
415 });
416
383 it("appends text for streaming messages", () => {417 it("appends text for streaming messages", () => {
384 const threadWithMessage: OrchestrationThread = {418 const threadWithMessage: OrchestrationThread = {
385 ...baseThread,419 ...baseThread,
...@@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => {...@@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => {
1178 });1212 });
11791213
1180 describe("thread.reverted", () => {1214 describe("thread.reverted", () => {
1215 it("keeps imported history and removes the first live prompt at checkpoint zero", () => {
1216 const threadWithImportedHistory: OrchestrationThread = {
1217 ...baseThread,
1218 messages: [
1219 {
1220 id: MessageId.make("import:codex:session-1:000000"),
1221 role: "user",
1222 text: "Imported prompt",
1223 turnId: null,
1224 streaming: false,
1225 createdAt: "2026-03-01T00:00:00.000Z",
1226 updatedAt: "2026-03-01T00:00:00.000Z",
1227 },
1228 {
1229 id: MessageId.make("import:codex:session-1:000001"),
1230 role: "assistant",
1231 text: "Imported answer",
1232 turnId: null,
1233 streaming: false,
1234 createdAt: "2026-03-01T00:01:00.000Z",
1235 updatedAt: "2026-03-01T00:01:00.000Z",
1236 },
1237 {
1238 id: MessageId.make("live-user-message"),
1239 role: "user",
1240 text: "New work",
1241 turnId: null,
1242 streaming: false,
1243 createdAt: "2026-04-01T01:00:00.000Z",
1244 updatedAt: "2026-04-01T01:00:00.000Z",
1245 },
1246 ],
1247 };
1248
1249 const result = applyThreadDetailEvent(threadWithImportedHistory, {
1250 ...baseEventFields,
1251 sequence: 14,
1252 occurredAt: "2026-04-01T02:00:00.000Z",
1253 aggregateKind: "thread",
1254 aggregateId: ThreadId.make("thread-1"),
1255 type: "thread.reverted",
1256 payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 },
1257 });
1258
1259 expect(result.kind).toBe("updated");
1260 if (result.kind === "updated") {
1261 expect(result.thread.messages.map((message) => message.text)).toEqual([
1262 "Imported prompt",
1263 "Imported answer",
1264 ]);
1265 }
1266 });
1267
1268 it("fallback-retains the earliest absolute timestamp across offsets", () => {
1269 const threadWithOffsetMessages: OrchestrationThread = {
1270 ...baseThread,
1271 messages: [
1272 {
1273 id: MessageId.make("earlier-by-offset"),
1274 role: "user",
1275 text: "Earlier",
1276 turnId: null,
1277 streaming: false,
1278 createdAt: "2026-04-01T10:30:00.000+02:00",
1279 updatedAt: "2026-04-01T10:30:00.000+02:00",
1280 },
1281 {
1282 id: MessageId.make("later-in-utc"),
1283 role: "user",
1284 text: "Later",
1285 turnId: null,
1286 streaming: false,
1287 createdAt: "2026-04-01T09:00:00.000Z",
1288 updatedAt: "2026-04-01T09:00:00.000Z",
1289 },
1290 ],
1291 };
1292
1293 const result = applyThreadDetailEvent(threadWithOffsetMessages, {
1294 ...baseEventFields,
1295 sequence: 14,
1296 occurredAt: "2026-04-01T10:00:00.000Z",
1297 aggregateKind: "thread",
1298 aggregateId: ThreadId.make("thread-1"),
1299 type: "thread.reverted",
1300 payload: { threadId: ThreadId.make("thread-1"), turnCount: 1 },
1301 });
1302
1303 expect(result.kind).toBe("updated");
1304 if (result.kind === "updated") {
1305 expect(result.thread.messages.map((message) => message.id)).toEqual(["earlier-by-offset"]);
1306 }
1307 });
1308
1181 it("filters entities to retained turns", () => {1309 it("filters entities to retained turns", () => {
1182 const threadWithData: OrchestrationThread = {1310 const threadWithData: OrchestrationThread = {
1183 ...baseThread,1311 ...baseThread,
packages/client-runtime/src/state/threadReducer.ts+42-10
...@@ -12,6 +12,8 @@ import type {...@@ -12,6 +12,8 @@ import type {
12 OrchestrationThreadActivity,12 OrchestrationThreadActivity,
13 TurnId,13 TurnId,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { isImportedAgentSessionMessageId } from "@t3tools/contracts";
16import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1517
16export type ThreadDetailReducerResult =18export type ThreadDetailReducerResult =
17 | { readonly kind: "updated"; readonly thread: OrchestrationThread }19 | { readonly kind: "updated"; readonly thread: OrchestrationThread }
...@@ -551,7 +553,11 @@ export function applyThreadDetailEvent(...@@ -551,7 +553,11 @@ export function applyThreadDetailEvent(
551 );553 );
552554
553 const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId));555 const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId));
554 const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds);556 const messages = retainMessagesAfterRevert(
557 thread.messages,
558 retainedTurnIds,
559 event.payload.turnCount,
560 );
555 const proposedPlans = pipe(561 const proposedPlans = pipe(
556 thread.proposedPlans,562 thread.proposedPlans,
557 Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)),563 Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)),
...@@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage(...@@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage(
744function retainMessagesAfterRevert(750function retainMessagesAfterRevert(
745 messages: ReadonlyArray<OrchestrationMessage>,751 messages: ReadonlyArray<OrchestrationMessage>,
746 retainedTurnIds: ReadonlySet<string>,752 retainedTurnIds: ReadonlySet<string>,
753 turnCount: number,
747): OrchestrationMessage[] {754): OrchestrationMessage[] {
748 // Keep messages that belong to a retained turn, plus system messages and755 const retainedMessageIds = new Set<string>();
749 // messages without a turn binding (pre-turn-0 user messages).756 for (const message of messages) {
750 return Arr.filter(messages, (message) => {757 if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) {
751 if (message.role === "system") {758 retainedMessageIds.add(message.id);
752 return true;759 } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
760 retainedMessageIds.add(message.id);
753 }761 }
754 if (message.turnId === null) {762 }
755 return true;763
764 for (const role of ["user", "assistant"] as const) {
765 const retainedCount = messages.filter(
766 (message) =>
767 message.role === role &&
768 !isImportedAgentSessionMessageId(message.id) &&
769 retainedMessageIds.has(message.id),
770 ).length;
771 const missingCount = Math.max(0, turnCount - retainedCount);
772 const fallbackMessages = messages
773 .filter(
774 (message) =>
775 message.role === role &&
776 !retainedMessageIds.has(message.id) &&
777 (message.turnId === null || retainedTurnIds.has(message.turnId)),
778 )
779 .toSorted(
780 (left, right) =>
781 compareDateTimeStrings(left.createdAt, right.createdAt) ||
782 left.id.localeCompare(right.id),
783 )
784 .slice(0, missingCount);
785 for (const message of fallbackMessages) {
786 retainedMessageIds.add(message.id);
756 }787 }
757 return retainedTurnIds.has(message.turnId);788 }
758 });789
790 return Arr.filter(messages, (message) => retainedMessageIds.has(message.id));
759}791}
packages/contracts/src/agentSessions.ts created+98
...@@ -0,0 +1,98 @@
1import * as Schema from "effect/Schema";
2import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ProviderInstanceId } from "./providerInstance.ts";
4
5/** Coding agent home directories the scanner knows how to read. */
6export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]);
7export type AgentSessionSource = typeof AgentSessionSource.Type;
8
9/** File identity saved with an imported session so bounded retries can skip unchanged history. */
10export const AgentSessionImportSource = Schema.Struct({
11 provider: AgentSessionSource,
12 providerInstanceId: ProviderInstanceId,
13 providerSessionId: TrimmedNonEmptyString,
14 filePath: TrimmedNonEmptyString,
15 size: NonNegativeInt,
16 mtimeMs: Schema.NullOr(Schema.Number),
17 device: Schema.Number,
18 inode: Schema.NullOr(Schema.Number),
19 birthtimeMs: Schema.NullOr(Schema.Number),
20});
21export type AgentSessionImportSource = typeof AgentSessionImportSource.Type;
22
23/** Imported message ids retain their origin after event metadata is projected into SQLite. */
24export function isImportedAgentSessionMessageId(messageId: string): boolean {
25 return messageId.startsWith("import:");
26}
27
28/**
29 * Empty for now. Kept as a struct so future scan options (source filters,
30 * explicit roots) can be added without a new method.
31 */
32export const AgentSessionScanInput = Schema.Struct({});
33export type AgentSessionScanInput = typeof AgentSessionScanInput.Type;
34
35/**
36 * A directory that at least one agent CLI has run in, suitable for import as a
37 * T3 Code project. `alreadyImported` marks candidates that already have an
38 * active project rooted at the same path.
39 */
40export const AgentSessionProjectCandidate = Schema.Struct({
41 path: TrimmedNonEmptyString,
42 title: TrimmedNonEmptyString,
43 projectId: Schema.optional(ProjectId),
44 sources: Schema.Array(AgentSessionSource),
45 threadCount: NonNegativeInt,
46 lastActiveAt: Schema.NullOr(IsoDateTime),
47 alreadyImported: Schema.Boolean,
48});
49export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;
50
51export const AgentSessionScanResult = Schema.Struct({
52 candidates: Schema.Array(AgentSessionProjectCandidate),
53 scannedAt: IsoDateTime,
54 truncated: Schema.optional(Schema.Boolean),
55});
56export type AgentSessionScanResult = typeof AgentSessionScanResult.Type;
57
58export const AgentSessionImportInput = Schema.Struct({
59 projectId: ProjectId,
60 expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString),
61});
62export type AgentSessionImportInput = typeof AgentSessionImportInput.Type;
63
64export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass<AgentSessionImportProjectNotFoundError>()(
65 "AgentSessionImportProjectNotFoundError",
66 { projectId: ProjectId },
67) {
68 override get message(): string {
69 return `Project '${this.projectId}' does not exist.`;
70 }
71}
72
73export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass<AgentSessionImportProjectChangedError>()(
74 "AgentSessionImportProjectChangedError",
75 { projectId: ProjectId },
76) {
77 override get message(): string {
78 return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`;
79 }
80}
81
82export const AgentSessionImportResult = Schema.Struct({
83 importedCount: NonNegativeInt,
84 skippedCount: NonNegativeInt,
85});
86export type AgentSessionImportResult = typeof AgentSessionImportResult.Type;
87
88export class AgentSessionScanError extends Schema.TaggedErrorClass<AgentSessionScanError>()(
89 "AgentSessionScanError",
90 {
91 operation: Schema.Literals(["read-settings", "read-projects"]),
92 cause: Schema.Defect(),
93 },
94) {
95 override get message(): string {
96 return `Failed to scan agent sessions during ${this.operation}.`;
97 }
98}
packages/contracts/src/index.ts+1
...@@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts";...@@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts";
29export * from "./editor.ts";29export * from "./editor.ts";
30export * from "./project.ts";30export * from "./project.ts";
31export * from "./filesystem.ts";31export * from "./filesystem.ts";
32export * from "./agentSessions.ts";
32export * from "./assets.ts";33export * from "./assets.ts";
33export * from "./review.ts";34export * from "./review.ts";
34export * from "./browserImport.ts";35export * from "./browserImport.ts";
packages/contracts/src/orchestration.test.ts+15
...@@ -1135,6 +1135,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>...@@ -1135,6 +1135,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
1135 }),1135 }),
1136);1136);
11371137
1138it.effect("rejects thread history imports without messages", () =>
1139 Effect.gen(function* () {
1140 const result = yield* Effect.exit(
1141 decodeOrchestrationCommand({
1142 type: "thread.history.import",
1143 commandId: "command-empty-history",
1144 threadId: "thread-1",
1145 messages: [],
1146 }),
1147 );
1148
1149 assert.strictEqual(result._tag, "Failure");
1150 }),
1151);
1152
1138it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => {1153it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => {
1139 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true);1154 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true);
1140 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);1155 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);
packages/contracts/src/orchestration.ts+17
...@@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({...@@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({
800 branch: Schema.NullOr(TrimmedNonEmptyString),800 branch: Schema.NullOr(TrimmedNonEmptyString),
801 worktreePath: Schema.NullOr(TrimmedNonEmptyString),801 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
802 createdAt: IsoDateTime,802 createdAt: IsoDateTime,
803 historyImport: Schema.optional(Schema.Literal(true)),
803});804});
804805
805const ThreadDeleteCommand = Schema.Struct({806const ThreadDeleteCommand = Schema.Struct({
...@@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({...@@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({
1122 createdAt: IsoDateTime,1123 createdAt: IsoDateTime,
1123});1124});
11241125
1126const ThreadHistoryImportCommand = Schema.Struct({
1127 type: Schema.Literal("thread.history.import"),
1128 commandId: CommandId,
1129 threadId: ThreadId,
1130 messages: Schema.Array(
1131 Schema.Struct({
1132 messageId: MessageId,
1133 role: Schema.Literals(["user", "assistant"]),
1134 text: Schema.String,
1135 createdAt: IsoDateTime,
1136 }),
1137 ).check(Schema.isNonEmpty()),
1138});
1139
1125const ThreadProposedPlanUpsertCommand = Schema.Struct({1140const ThreadProposedPlanUpsertCommand = Schema.Struct({
1126 type: Schema.Literal("thread.proposed-plan.upsert"),1141 type: Schema.Literal("thread.proposed-plan.upsert"),
1127 commandId: CommandId,1142 commandId: CommandId,
...@@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([...@@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([
1173 ThreadSessionSetCommand,1188 ThreadSessionSetCommand,
1174 ThreadMessageAssistantDeltaCommand,1189 ThreadMessageAssistantDeltaCommand,
1175 ThreadMessageAssistantCompleteCommand,1190 ThreadMessageAssistantCompleteCommand,
1191 ThreadHistoryImportCommand,
1176 ThreadProposedPlanUpsertCommand,1192 ThreadProposedPlanUpsertCommand,
1177 ThreadTurnDiffCompleteCommand,1193 ThreadTurnDiffCompleteCommand,
1178 ThreadActivityAppendCommand,1194 ThreadActivityAppendCommand,
...@@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({...@@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({
1473 adapterKey: Schema.optional(TrimmedNonEmptyString),1489 adapterKey: Schema.optional(TrimmedNonEmptyString),
1474 requestId: Schema.optional(ApprovalRequestId),1490 requestId: Schema.optional(ApprovalRequestId),
1475 ingestedAt: Schema.optional(IsoDateTime),1491 ingestedAt: Schema.optional(IsoDateTime),
1492 historyImport: Schema.optional(Schema.Boolean),
1476 origin: Schema.optional(OrchestrationClientOrigin),1493 origin: Schema.optional(OrchestrationClientOrigin),
1477});1494});
1478export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;1495export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;
packages/contracts/src/rpc.ts+30
...@@ -28,6 +28,15 @@ import {...@@ -28,6 +28,15 @@ import {
28 FilesystemBrowseResult,28 FilesystemBrowseResult,
29 FilesystemBrowseError,29 FilesystemBrowseError,
30} from "./filesystem.ts";30} from "./filesystem.ts";
31import {
32 AgentSessionImportInput,
33 AgentSessionImportProjectChangedError,
34 AgentSessionImportProjectNotFoundError,
35 AgentSessionImportResult,
36 AgentSessionScanInput,
37 AgentSessionScanResult,
38 AgentSessionScanError,
39} from "./agentSessions.ts";
31import {40import {
32 AssetAccessError,41 AssetAccessError,
33 AssetCreateUrlInput,42 AssetCreateUrlInput,
...@@ -240,6 +249,8 @@ export const WS_METHODS = {...@@ -240,6 +249,8 @@ export const WS_METHODS = {
240249
241 // Filesystem methods250 // Filesystem methods
242 filesystemBrowse: "filesystem.browse",251 filesystemBrowse: "filesystem.browse",
252 agentSessionsScan: "agentSessions.scan",
253 agentSessionsImport: "agentSessions.import",
243 assetsCreateUrl: "assets.createUrl",254 assetsCreateUrl: "assets.createUrl",
244 attachmentsCreateUploadUrl: "attachments.createUploadUrl",255 attachmentsCreateUploadUrl: "attachments.createUploadUrl",
245 attachmentsDelete: "attachments.delete",256 attachmentsDelete: "attachments.delete",
...@@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {...@@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
823 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),834 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
824});835});
825836
837export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, {
838 payload: AgentSessionScanInput,
839 success: AgentSessionScanResult,
840 error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]),
841});
842
843export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, {
844 payload: AgentSessionImportInput,
845 success: AgentSessionImportResult,
846 error: Schema.Union([
847 AgentSessionImportProjectChangedError,
848 AgentSessionImportProjectNotFoundError,
849 AgentSessionScanError,
850 EnvironmentAuthorizationError,
851 ]),
852});
853
826export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {854export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
827 payload: AssetCreateUrlInput,855 payload: AssetCreateUrlInput,
828 success: AssetCreateUrlResult,856 success: AssetCreateUrlResult,
...@@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make(
1241 WsProjectsWriteFileRpc,1269 WsProjectsWriteFileRpc,
1242 WsShellOpenInEditorRpc,1270 WsShellOpenInEditorRpc,
1243 WsFilesystemBrowseRpc,1271 WsFilesystemBrowseRpc,
1272 WsAgentSessionsScanRpc,
1273 WsAgentSessionsImportRpc,
1244 WsAssetsCreateUrlRpc,1274 WsAssetsCreateUrlRpc,
1245 WsAttachmentsCreateUploadUrlRpc,1275 WsAttachmentsCreateUploadUrlRpc,
1246 WsAttachmentsDeleteRpc,1276 WsAttachmentsDeleteRpc,
packages/contracts/src/server.ts+3
...@@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({...@@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({
748 environment: ExecutionEnvironmentDescriptor,748 environment: ExecutionEnvironmentDescriptor,
749 cwd: TrimmedNonEmptyString,749 cwd: TrimmedNonEmptyString,
750 projectName: TrimmedNonEmptyString,750 projectName: TrimmedNonEmptyString,
751 bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])),
751 bootstrapProjectId: Schema.optional(ProjectId),752 bootstrapProjectId: Schema.optional(ProjectId),
752 bootstrapThreadId: Schema.optional(ThreadId),753 bootstrapThreadId: Schema.optional(ThreadId),
754 bootstrapProjectCreated: Schema.optional(Schema.Boolean),
755 bootstrapThreadCreated: Schema.optional(Schema.Boolean),
753});756});
754export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type;757export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type;
755758
packages/contracts/src/settings.ts+8
...@@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({
286 // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes);286 // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes);
287 // disabling restores the platform's heavier default. No effect off macOS.287 // disabling restores the platform's heavier default. No effect off macOS.
288 fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),288 fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
289 // When the first-run welcome wizard finished (or was skipped), as an ISO
290 // timestamp. `null` alone does not mean "show the wizard" — every install
291 // that predates this field decodes to `null` — so the gate also requires an
292 // empty workspace before it treats the client as a fresh install.
293 onboardingCompletedAt: Schema.NullOr(Schema.String).pipe(
294 Schema.withDecodingDefault(Effect.succeed(null)),
295 ),
289 // Model favorites. Historically keyed by provider kind, now296 // Model favorites. Historically keyed by provider kind, now
290 // widened to `ProviderInstanceId` so users can favorite a specific model297 // widened to `ProviderInstanceId` so users can favorite a specific model
291 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without298 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
...@@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({
1188 diffLayout: Schema.optionalKey(DiffLayout),1195 diffLayout: Schema.optionalKey(DiffLayout),
1189 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),1196 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
1190 glassOpacity: Schema.optionalKey(GlassOpacity),1197 glassOpacity: Schema.optionalKey(GlassOpacity),
1198 onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)),
1191 fontSizeInterface: Schema.optionalKey(InterfaceFontSize),1199 fontSizeInterface: Schema.optionalKey(InterfaceFontSize),
1192 fontSizePrompt: Schema.optionalKey(PromptFontSize),1200 fontSizePrompt: Schema.optionalKey(PromptFontSize),
1193 fontSizeCode: Schema.optionalKey(CodeFontSize),1201 fontSizeCode: Schema.optionalKey(CodeFontSize),
packages/contracts/src/terminal.test.ts+43
...@@ -7,12 +7,18 @@ import {...@@ -7,12 +7,18 @@ import {
7 TerminalClearInput,7 TerminalClearInput,
8 TerminalCloseInput,8 TerminalCloseInput,
9 TerminalEvent,9 TerminalEvent,
10 TerminalError,
10 TerminalOpenInput,11 TerminalOpenInput,
12 TerminalProviderEnvironmentError,
11 TerminalResizeInput,13 TerminalResizeInput,
12 TerminalSessionSnapshot,14 TerminalSessionSnapshot,
13 TerminalThreadInput,15 TerminalThreadInput,
14 TerminalWriteInput,16 TerminalWriteInput,
15} from "./terminal.ts";17} from "./terminal.ts";
18import { ProviderInstanceId } from "./providerInstance.ts";
19
20const encodeTerminalError = Schema.encodeUnknownSync(TerminalError);
21const decodeTerminalError = Schema.decodeUnknownSync(TerminalError);
1622
17function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> {23function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> {
18 return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;24 return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;
...@@ -27,6 +33,28 @@ function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean {...@@ -27,6 +33,28 @@ function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean {
27 }33 }
28}34}
2935
36describe("TerminalProviderEnvironmentError", () => {
37 it("round-trips its required cause without exposing it in the message", () => {
38 const cause = { operation: "read-secret", detail: "secret backend unavailable" };
39 const error = new TerminalProviderEnvironmentError({
40 providerInstanceId: ProviderInstanceId.make("codex_work"),
41 cause,
42 });
43 const encoded = encodeTerminalError(error);
44 const decoded = decodeTerminalError(encoded);
45
46 expect(decoded).toMatchObject({
47 _tag: "TerminalProviderEnvironmentError",
48 providerInstanceId: "codex_work",
49 cause,
50 });
51 expect(decoded.message).toBe(
52 "Could not prepare the terminal environment for provider instance: codex_work",
53 );
54 expect(decoded.message).not.toContain("secret backend unavailable");
55 });
56});
57
30describe("TerminalOpenInput", () => {58describe("TerminalOpenInput", () => {
31 it("accepts valid open input", () => {59 it("accepts valid open input", () => {
32 expect(60 expect(
...@@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => {...@@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => {
87 T3CODE_PROJECT_ROOT: "/tmp/project",115 T3CODE_PROJECT_ROOT: "/tmp/project",
88 CUSTOM_FLAG: "1",116 CUSTOM_FLAG: "1",
89 },117 },
118 providerInstanceId: "codex_work",
90 });119 });
91 expect(parsed.env).toMatchObject({120 expect(parsed.env).toMatchObject({
92 T3CODE_PROJECT_ROOT: "/tmp/project",121 T3CODE_PROJECT_ROOT: "/tmp/project",
93 CUSTOM_FLAG: "1",122 CUSTOM_FLAG: "1",
94 });123 });
95 expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a");124 expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a");
125 expect(parsed.providerInstanceId).toBe("codex_work");
96 });126 });
97127
98 it("rejects invalid env keys", () => {128 it("rejects invalid env keys", () => {
...@@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => {...@@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => {
108 }),138 }),
109 ).toBe(false);139 ).toBe(false);
110 });140 });
141
142 it("rejects invalid provider instance ids", () => {
143 for (const providerInstanceId of ["", "1invalid", "invalid id"]) {
144 expect(
145 decodes(TerminalOpenInput, {
146 threadId: "thread-1",
147 terminalId: DEFAULT_TERMINAL_ID,
148 cwd: "/tmp/project",
149 providerInstanceId,
150 }),
151 ).toBe(false);
152 }
153 });
111});154});
112155
113describe("TerminalAttachInput", () => {156describe("TerminalAttachInput", () => {
packages/contracts/src/terminal.ts+32-3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ProviderInstanceId } from "./providerInstance.ts";
34
4/**5/**
5 * Client-side id for the first shell opened on a thread. Ids are uniformly6 * Client-side id for the first shell opened on a thread. Ids are uniformly
...@@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({...@@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({
43 cols: Schema.optional(TerminalColsSchema),44 cols: Schema.optional(TerminalColsSchema),
44 rows: Schema.optional(TerminalRowsSchema),45 rows: Schema.optional(TerminalRowsSchema),
45 env: Schema.optional(TerminalEnvSchema),46 env: Schema.optional(TerminalEnvSchema),
47 providerInstanceId: Schema.optional(ProviderInstanceId),
46});48});
47export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;49export type TerminalOpenInput = typeof TerminalOpenInput.Type;
4850
49export const TerminalAttachInput = Schema.Struct({51export const TerminalAttachInput = Schema.Struct({
50 ...TerminalSessionInput.fields,52 ...TerminalSessionInput.fields,
...@@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({...@@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({
53 cols: Schema.optional(TerminalColsSchema),55 cols: Schema.optional(TerminalColsSchema),
54 rows: Schema.optional(TerminalRowsSchema),56 rows: Schema.optional(TerminalRowsSchema),
55 env: Schema.optional(TerminalEnvSchema),57 env: Schema.optional(TerminalEnvSchema),
58 providerInstanceId: Schema.optional(ProviderInstanceId),
56 restartIfNotRunning: Schema.optional(Schema.Boolean),59 restartIfNotRunning: Schema.optional(Schema.Boolean),
57});60});
58export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>;61export type TerminalAttachInput = typeof TerminalAttachInput.Type;
5962
60export const TerminalWriteInput = Schema.Struct({63export const TerminalWriteInput = Schema.Struct({
61 ...TerminalSessionInput.fields,64 ...TerminalSessionInput.fields,
...@@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({...@@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({
80 cols: TerminalColsSchema,83 cols: TerminalColsSchema,
81 rows: TerminalRowsSchema,84 rows: TerminalRowsSchema,
82 env: Schema.optional(TerminalEnvSchema),85 env: Schema.optional(TerminalEnvSchema),
86 providerInstanceId: Schema.optional(ProviderInstanceId),
83});87});
84export type TerminalRestartInput = Schema.Codec.Encoded<typeof TerminalRestartInput>;88export type TerminalRestartInput = typeof TerminalRestartInput.Type;
8589
86export const TerminalCloseInput = Schema.Struct({90export const TerminalCloseInput = Schema.Struct({
87 ...TerminalThreadInput.fields,91 ...TerminalThreadInput.fields,
...@@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass<Terminal...@@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass<Terminal
299 }303 }
300}304}
301305
306export class TerminalProviderInstanceNotFoundError extends Schema.TaggedErrorClass<TerminalProviderInstanceNotFoundError>()(
307 "TerminalProviderInstanceNotFoundError",
308 {
309 providerInstanceId: ProviderInstanceId,
310 },
311) {
312 override get message() {
313 return `Provider instance is not available: ${this.providerInstanceId}`;
314 }
315}
316
317export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass<TerminalProviderEnvironmentError>()(
318 "TerminalProviderEnvironmentError",
319 {
320 providerInstanceId: ProviderInstanceId,
321 cause: Schema.Defect(),
322 },
323) {
324 override get message() {
325 return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`;
326 }
327}
328
302export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(329export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(
303 "TerminalNotRunningError",330 "TerminalNotRunningError",
304 {331 {
...@@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([...@@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([
345 TerminalCwdError,372 TerminalCwdError,
346 TerminalHistoryError,373 TerminalHistoryError,
347 TerminalSessionLookupError,374 TerminalSessionLookupError,
375 TerminalProviderInstanceNotFoundError,
376 TerminalProviderEnvironmentError,
348 TerminalNotRunningError,377 TerminalNotRunningError,
349 TerminalWriteError,378 TerminalWriteError,
350 TerminalResizeError,379 TerminalResizeError,
packages/shared/package.json+4
...@@ -270,6 +270,10 @@...@@ -270,6 +270,10 @@
270 "./hostClassification": {270 "./hostClassification": {
271 "types": "./src/hostClassification.ts",271 "types": "./src/hostClassification.ts",
272 "import": "./src/hostClassification.ts"272 "import": "./src/hostClassification.ts"
273 },
274 "./dateTime": {
275 "types": "./src/dateTime.ts",
276 "import": "./src/dateTime.ts"
273 }277 }
274 },278 },
275 "scripts": {279 "scripts": {
packages/shared/src/dateTime.test.ts created+92
...@@ -0,0 +1,92 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { compareDateTimeStrings } from "./dateTime.ts";
4
5describe("compareDateTimeStrings", () => {
6 it("compares valid date-time strings by absolute time", () => {
7 expect(
8 compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"),
9 ).toBe(0);
10 expect(
11 compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"),
12 ).toBeGreaterThan(0);
13 });
14
15 it.each([
16 ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"],
17 ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"],
18 ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"],
19 ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"],
20 ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"],
21 ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"],
22 ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"],
23 ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"],
24 ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"],
25 ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"],
26 ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"],
27 ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"],
28 ])("preserves equal ISO instants %s and %s", (left, right) => {
29 expect(compareDateTimeStrings(left, right)).toBe(0);
30 });
31
32 it("sorts malformed values before valid values", () => {
33 expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0);
34 expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0);
35 });
36
37 it.each([
38 "2014-02-30",
39 "2014-03-02",
40 "2014-03-02T00:00:00",
41 "2014-03-02T00:00:00.000",
42 "03/02/2014",
43 "March 2, 2014",
44 "Sun, 02 Mar 2014 00:00:00 GMT",
45 "2014-03-02T00:00:00.000Z\n",
46 "2014-02-30T00:00:00.000Z",
47 "1900-02-29T00:00:00.000-07:00",
48 "2024-04-31T00:00:00.000+05:30",
49 "2024-03-02T12:00:00.000+24:00",
50 "2026-09-01T24:01:00Z",
51 "2026-09-01T24:00:01Z",
52 "2026-09-01T24:00:00.0001Z",
53 "2026-09-01T24:01Z",
54 "2026-09-01T25:00Z",
55 ])("treats %s as malformed without native date guessing", (malformed) => {
56 const valid = "1970-01-01T00:00:00.000Z";
57 expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0);
58 expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0);
59 expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0);
60 expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0);
61 expect(compareDateTimeStrings(malformed, malformed)).toBe(0);
62 });
63
64 it("uses code-unit order for malformed date-time strings", () => {
65 expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0);
66 expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0);
67 });
68
69 it("returns zero for equal malformed date-time strings", () => {
70 expect(compareDateTimeStrings("invalid", "invalid")).toBe(0);
71 });
72
73 it("gives every permutation of mixed values the same order", () => {
74 const early = "2026-09-01T12:00:00.000+14:00";
75 const late = "2026-09-01T00:00:00.000-12:00";
76 const malformed = "2026-09-01T06:invalid";
77 const expected = [malformed, early, late];
78
79 const permutations = [
80 [early, late, malformed],
81 [early, malformed, late],
82 [late, early, malformed],
83 [late, malformed, early],
84 [malformed, early, late],
85 [malformed, late, early],
86 ];
87
88 for (const values of permutations) {
89 expect(values.toSorted(compareDateTimeStrings)).toEqual(expected);
90 }
91 });
92});
packages/shared/src/dateTime.ts created+38
...@@ -0,0 +1,38 @@
1import * as DateTime from "effect/DateTime";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5const isZonedIsoDateTime = Schema.is(
6 Schema.String.check(
7 Schema.isPattern(
8 /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/,
9 ),
10 Schema.isTrimmed(),
11 ),
12);
13
14function parseTimestamp(value: string): number {
15 if (!isZonedIsoDateTime(value)) return Number.NaN;
16
17 // Engines can normalize invalid calendar dates instead of rejecting them.
18 const datePart = value.slice(0, value.indexOf("T"));
19 const date = DateTime.make(`${datePart}T00:00:00.000Z`);
20 if (Option.isNone(date)) return Number.NaN;
21 const parts = DateTime.toPartsUtc(date.value);
22 if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) {
23 return Number.NaN;
24 }
25 return Date.parse(value);
26}
27
28/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */
29export function compareDateTimeStrings(left: string, right: string): number {
30 const leftTimestamp = parseTimestamp(left);
31 const rightTimestamp = parseTimestamp(right);
32 const leftIsValid = !Number.isNaN(leftTimestamp);
33 const rightIsValid = !Number.isNaN(rightTimestamp);
34
35 if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1;
36 if (leftIsValid) return leftTimestamp - rightTimestamp;
37 return left < right ? -1 : left > right ? 1 : 0;
38}