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>) => {
5454 const environment = yield* DesktopEnvironment.DesktopEnvironment;
5555 const settings = yield* DesktopClientSettings.DesktopClientSettings;
5656 return {
57 result: yield* settings.get,
57 result: yield* Effect.result(settings.get),
5858 settingsPath: environment.clientSettingsPath,
5959 records,
6060 };
......@@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => {
7373 Effect.gen(function* () {
7474 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));
7778 assert.deepEqual(result.records, []);
7879 }),
7980 );
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", () => {
8283 const permissionError = PlatformError.systemError({
8384 _tag: "PermissionDenied",
8485 module: "FileSystem",
......@@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => {
9394 }),
9495 );
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);
97103 assert.equal(result.records.length, 1);
98104 assert.deepEqual(result.records[0]?.message, [
99105 "Could not read desktop client settings.",
......@@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => {
103109 });
104110 });
105111
106 it.effect("logs malformed settings documents with the settings path", () =>
112 it.effect("reports malformed settings documents and logs the settings path", () =>
107113 Effect.gen(function* () {
108114 const result = yield* readWithLogs(
109115 FileSystem.layerNoop({
......@@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => {
111117 }),
112118 );
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");
115126 assert.equal(result.records.length, 1);
116127 const message = result.records[0]?.message;
117128 if (!Array.isArray(message)) {
apps/desktop/src/settings/DesktopClientSettings.test.ts+80-12
......@@ -44,6 +44,7 @@ const clientSettings: ClientSettings = {
4444 fontSizeTerminal: 12,
4545 fontSmoothing: true,
4646 glassOpacity: 80,
47 onboardingCompletedAt: null,
4748 panelAnimationDurationMs: 0,
4849 planModeEnabled: false,
4950 proactivePanelsEnabled: true,
......@@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => {
136137 ),
137138 );
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
139193 it.effect("reports the failed client settings write operation and path", () =>
140194 withClientSettings(
141195 Effect.gen(function* () {
......@@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => {
222276 ),
223277 );
224278
225 it.effect("treats malformed client settings documents as absent", () =>
226 withClientSettings(
227 Effect.gen(function* () {
228 const environment = yield* DesktopEnvironment.DesktopEnvironment;
229 const fileSystem = yield* FileSystem.FileSystem;
230 const settings = yield* DesktopClientSettings.DesktopClientSettings;
231 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
232 yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json");
279 for (const document of [
280 { label: "malformed JSON", contents: "{not-json" },
281 { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' },
282 { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' },
283 ]) {
284 it.effect(`reports ${document.label} without treating the settings file as absent`, () =>
285 withClientSettings(
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));
235 }),
236 ),
237 );
293 const error = yield* settings.get.pipe(Effect.flip);
294 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
295 assert.equal(error.operation, "decode-document");
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 }
238306});
apps/desktop/src/settings/DesktopClientSettings.ts+42-18
......@@ -12,25 +12,33 @@ import * as Ref from "effect/Ref";
1212
1313import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1414
15const ClientSettingsDocumentSchema = Schema.Struct({
16 settings: ClientSettingsSchema,
17});
18
1915const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);
20const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema);
21const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect(
22 LegacyClientSettingsDocumentJson,
16const decodeClientSettingsDocument = Schema.decodeEffect(
17 fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)),
2318);
24const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson);
25const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> =>
26 decodeLegacyClientSettingsDocumentJson(raw).pipe(
27 Effect.map((document) => document.settings),
28 Effect.catchTags({
29 SchemaError: () => decodeClientSettingsJsonValue(raw),
30 }),
19const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema);
20const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) {
21 const document = yield* decodeClientSettingsDocument(raw);
22 // Select the shape before validation so invalid legacy settings cannot become defaults.
23 return yield* decodeClientSettingsValue(
24 Object.hasOwn(document, "settings") ? document.settings : document,
3125 );
26});
3227const 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
3442const DesktopClientSettingsWriteOperation = Schema.Literals([
3543 "create-temporary-file-name",
3644 "encode-document",
......@@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<Des
5563export class DesktopClientSettings extends Context.Service<
5664 DesktopClientSettings,
5765 {
58 readonly get: Effect.Effect<Option.Option<ClientSettings>>;
66 readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>;
5967 readonly set: (
6068 settings: ClientSettings,
6169 ) => Effect.Effect<void, DesktopClientSettingsWriteError>;
......@@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service<
6573const readClientSettings = (
6674 fileSystem: FileSystem.FileSystem,
6775 settingsPath: string,
68): Effect.Effect<Option.Option<ClientSettings>> =>
76): Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError> =>
6977 fileSystem.readFileString(settingsPath).pipe(
7078 Effect.map(Option.some),
7179 Effect.catchTags({
......@@ -74,7 +82,15 @@ const readClientSettings = (
7482 ? Effect.succeed(Option.none<string>())
7583 : Effect.logWarning("Could not read desktop client settings.", cause).pipe(
7684 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 ),
7894 ),
7995 }),
8096 Effect.flatMap(
......@@ -87,7 +103,15 @@ const readClientSettings = (
87103 SchemaError: (cause) =>
88104 Effect.logWarning("Could not decode desktop client settings.", cause).pipe(
89105 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 ),
91115 ),
92116 }),
93117 ),
apps/server/src/auth/RpcAuthorization.test.ts+9
......@@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => {
4343 );
4444 });
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
4655 it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
4756 // The candidate list is a read like the detail beside it, and asking somebody for a review is
4857 // a write like every other pull request operation.
apps/server/src/auth/RpcAuthorization.ts+2
......@@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = {
9999 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
100100 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
101101 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
102 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
103 [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
102104 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
103105 [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
104106 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
......@@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => {
9090 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
9191 getProjectShellById: () => Effect.succeed(Option.none()),
9292 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
93 getImportedAgentSessionSources: () => Effect.die("unused"),
9394 getThreadCheckpointContext: () =>
9495 Effect.sync(() => {
9596 getThreadCheckpointContextCalls += 1;
......@@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => {
202203 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
203204 getProjectShellById: () => Effect.succeed(Option.none()),
204205 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
206 getImportedAgentSessionSources: () => Effect.die("unused"),
205207 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
206208 getFullThreadDiffContext: () => Effect.die("unused"),
207209 getThreadRuntimeContext: () => Effect.die("unused"),
......@@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => {
289291 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
290292 getProjectShellById: () => Effect.succeed(Option.none()),
291293 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
294 getImportedAgentSessionSources: () => Effect.die("unused"),
292295 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
293296 getFullThreadDiffContext: () => Effect.die("unused"),
294297 getThreadRuntimeContext: () => Effect.die("unused"),
......@@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => {
361364 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
362365 getProjectShellById: () => Effect.succeed(Option.none()),
363366 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
367 getImportedAgentSessionSources: () => Effect.die("unused"),
364368 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
365369 getFullThreadDiffContext: () => Effect.die("unused"),
366370 getThreadRuntimeContext: () => Effect.die("unused"),
......@@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => {
418422 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
419423 getProjectShellById: () => Effect.succeed(Option.none()),
420424 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
425 getImportedAgentSessionSources: () => Effect.die("unused"),
421426 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
422427 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
423428 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+30
......@@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => {
13421342 ).toBe("v1\n");
13431343 });
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
13451375 it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => {
13461376 const harness = await createHarness({
13471377 hasSession: false,
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1
......@@ -615,6 +615,7 @@ const make = Effect.gen(function* () {
615615 ) {
616616 if (event.type === "thread.message-sent") {
617617 if (
618 event.metadata.historyImport === true ||
618619 event.payload.role !== "user" ||
619620 event.payload.streaming ||
620621 event.payload.turnId !== null
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
......@@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => {
418418 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
419419 getProjectShellById: () => Effect.succeed(Option.none()),
420420 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
421 getImportedAgentSessionSources: () => Effect.die("unused"),
421422 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
422423 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
423424 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
108108 },
109109);
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
111207it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
112208 it.effect("bootstraps all projection states and writes projection rows", () =>
113209 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+14-5
......@@ -1,10 +1,12 @@
11import {
22 ApprovalRequestId,
3 isImportedAgentSessionMessageId,
34 type ChatAttachment,
45 type OrchestrationEvent,
56 type OrchestrationSessionStatus,
67 ThreadId,
78} from "@t3tools/contracts";
9import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
810import * as Effect from "effect/Effect";
911import * as FileSystem from "effect/FileSystem";
1012import * as Layer from "effect/Layer";
......@@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert(
248250 }
249251
250252 for (const message of messages) {
251 if (message.role === "system") {
253 if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) {
252254 retainedMessageIds.add(message.messageId);
253255 continue;
254256 }
......@@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert(
258260 }
259261
260262 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),
262267 ).length;
263268 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
264269 if (missingUserCount > 0) {
......@@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert(
271276 )
272277 .toSorted(
273278 (left, right) =>
274 left.createdAt.localeCompare(right.createdAt) ||
279 compareDateTimeStrings(left.createdAt, right.createdAt) ||
275280 left.messageId.localeCompare(right.messageId),
276281 )
277282 .slice(0, missingUserCount);
......@@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert(
281286 }
282287
283288 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),
285293 ).length;
286294 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
287295 if (missingAssistantCount > 0) {
......@@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert(
294302 )
295303 .toSorted(
296304 (left, right) =>
297 left.createdAt.localeCompare(right.createdAt) ||
305 compareDateTimeStrings(left.createdAt, right.createdAt) ||
298306 left.messageId.localeCompare(right.messageId),
299307 )
300308 .slice(0, missingAssistantCount);
......@@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
903911 updatedAt: event.occurredAt,
904912 latestUserMessageAt:
905913 event.payload.role === "user" &&
914 !isImportedAgentSessionMessageId(event.payload.messageId) &&
906915 (previousLatest === null || event.payload.createdAt > previousLatest)
907916 ? event.payload.createdAt
908917 : previousLatest,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+298-1
......@@ -1,4 +1,5 @@
11import {
2 type AgentSessionImportSource,
23 CheckpointRef,
34 EventId,
45 MessageId,
......@@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest";
1112import * as NodeServices from "@effect/platform-node/NodeServices";
1213import * as Effect from "effect/Effect";
1314import * as Layer from "effect/Layer";
15import * as Schema from "effect/Schema";
1416import * as SqlClient from "effect/unstable/sql/SqlClient";
1517
1618import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
......@@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
21342136 //
21352137 // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id)
21362138 // 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 }) {
21382142 const sql = yield* SqlClient.SqlClient;
21392143
21402144 // Tests in this block share one in-memory database; reset before seeding.
......@@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
21632167 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL)
21642168 `;
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
21662184 const turns: ReadonlyArray<{
21672185 turn: string;
21682186 pendingMessage: string | null;
......@@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
23962414 }),
23972415 );
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
23992462 it.effect("a cursor for a different thread degrades to the first page", () =>
24002463 Effect.gen(function* () {
24012464 yield* seedFanOutThread();
......@@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
27792842 }),
27802843 );
27812844});
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 @@
11import {
2 AgentSessionImportSource,
23 ApprovalRequestId,
34 ChatAttachment,
45 CheckpointRef,
......@@ -75,6 +76,14 @@ import {
7576const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
7677const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);
7778const 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);
7887// Keep detail reads consistent with the in-memory projector's retained
7988// activity window. Applying the limit in SQL avoids decoding an unbounded
8089// payload_json set before the projector can enforce that invariant.
......@@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({
164173const ProjectIdLookupInput = Schema.Struct({
165174 projectId: ProjectId,
166175});
176const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({
177 threadId: ThreadId,
178 runtimePayload: Schema.Unknown,
179});
167180const ThreadIdLookupInput = Schema.Struct({
168181 threadId: ThreadId,
169182});
......@@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
983996 `,
984997 });
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
9861026 const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({
9871027 Request: ThreadIdLookupInput,
9881028 Result: ProjectionThreadCheckpointContextThreadRowSchema,
......@@ -2663,6 +2703,33 @@ pending_approval_requests AS (
26632703 Effect.map(Option.map((row) => row.threadId)),
26642704 );
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
26662733 const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = (
26672734 threadId,
26682735 ) =>
......@@ -3151,17 +3218,35 @@ pending_approval_requests AS (
31513218 );
31523219
31533220 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;
31543237 // An empty window (no turns before the cursor, or a thread with no
31553238 // turns at all) still returns thread metadata with empty collections
31563239 // for turn-linked rows; turnless rows are bounded to the same empty
31573240 // 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.
31593244 const bounds: ThreadDetailBounds | undefined =
31603245 oldest === undefined && cursor === null
31613246 ? undefined
31623247 : {
3163 minAnchorAt: oldest?.anchorAt ?? "",
3164 minTurnKey: oldest?.turnKey ?? "",
3248 minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "",
3249 minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "",
31653250 beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED,
31663251 beforeTurnKey: cursor?.beforeTurnId ?? "",
31673252 };
......@@ -3178,23 +3263,6 @@ pending_approval_requests AS (
31783263 return Option.none<OrchestrationThreadDetailSnapshot>();
31793264 }
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
31983266 const { snapshotSequence } = yield* getSnapshotSequence();
31993267 const watermarkRow = yield* getThreadEventWatermarkRow({
32003268 threadId,
......@@ -3253,6 +3321,7 @@ pending_approval_requests AS (
32533321 getActiveProjectByWorkspaceRoot,
32543322 getProjectShellById,
32553323 getFirstActiveThreadIdByProjectId,
3324 getImportedAgentSessionSources,
32563325 getThreadCheckpointContext,
32573326 getFullThreadDiffContext,
32583327 getThreadShellById,
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+10
......@@ -7,6 +7,7 @@
77 * @module ProjectionSnapshotQuery
88 */
99import type {
10 AgentSessionImportSource,
1011 ApprovalRequestId,
1112 CheckpointRef,
1213 OrchestrationCheckpointSummary,
......@@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape {
170171 projectId: ProjectId,
171172 ) => 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
173183 /**
174184 * Read the checkpoint context needed to resolve a single thread diff.
175185 */
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 {
22 EventId,
33 MessageId,
44 UserInputRequestedPayload,
5 isImportedAgentSessionMessageId,
56 type OrchestrationCommand,
67 type OrchestrationEvent,
78 type OrchestrationReadModel,
89 type OrchestrationThread,
910 type OrchestrationThreadActivity,
1011} from "@t3tools/contracts";
12import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1113import * as DateTime from "effect/DateTime";
1214import * as Crypto from "effect/Crypto";
1315import * as Effect from "effect/Effect";
......@@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread(
98100 let latestUserMessageAt: string | null = null;
99101 let latestUserMessageAtMs = Number.NEGATIVE_INFINITY;
100102 for (const message of thread.messages) {
101 if (message.role !== "user") continue;
103 if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue;
102104 const messageAtMs = Date.parse(message.createdAt);
103105 latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs);
104106 if (messageAtMs === latestUserMessageAtMs) {
......@@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
347349 aggregateId: command.threadId,
348350 occurredAt: command.createdAt,
349351 commandId: command.commandId,
352 ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}),
350353 })),
351354 type: "thread.created",
352355 payload: {
......@@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
905908 }
906909
907910 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 }
908917 const targetThread = yield* requireThread({
909918 readModel,
910919 command,
......@@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
12721281 }
12731282
12741283 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 }
12751290 yield* requireThread({
12761291 readModel,
12771292 command,
......@@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
12991314 }
13001315
13011316 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 }
13021323 yield* requireThread({
13031324 readModel,
13041325 command,
......@@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
13251346 };
13261347 }
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
13281422 case "thread.proposed-plan.upsert": {
13291423 yield* requireThread({
13301424 readModel,
apps/server/src/orchestration/projector.ts+15-5
......@@ -1,10 +1,12 @@
11import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";
22import {
3 isImportedAgentSessionMessageId,
34 OrchestrationCheckpointSummary,
45 OrchestrationMessage,
56 OrchestrationSession,
67 OrchestrationThread,
78} from "@t3tools/contracts";
9import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
810import * as Effect from "effect/Effect";
911import * as Schema from "effect/Schema";
1012import * as Predicate from "effect/Predicate";
......@@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert(
117119): ReadonlyArray<OrchestrationMessage> {
118120 const retainedMessageIds = new Set<string>();
119121 for (const message of messages) {
120 if (message.role === "system") {
122 if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) {
121123 retainedMessageIds.add(message.id);
122124 continue;
123125 }
......@@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert(
127129 }
128130
129131 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),
131136 ).length;
132137 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
133138 if (missingUserCount > 0) {
......@@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert(
140145 )
141146 .toSorted(
142147 (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),
144150 )
145151 .slice(0, missingUserCount);
146152 for (const message of fallbackUserMessages) {
......@@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert(
149155 }
150156
151157 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),
153162 ).length;
154163 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
155164 if (missingAssistantCount > 0) {
......@@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert(
162171 )
163172 .toSorted(
164173 (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),
166176 )
167177 .slice(0, missingAssistantCount);
168178 for (const message of fallbackAssistantMessages) {
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+13-1
......@@ -12,12 +12,24 @@ const layer = it.layer(
1212);
1313
1414layer("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", () =>
1616 Effect.gen(function* () {
1717 const repository = yield* ProjectionThreadMessageRepository;
1818 const threadId = ThreadId.make("thread-latest-user-message");
1919 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
2133 const messages = [
2234 { role: "user", createdAt: "2026-02-28T19:05:02.000Z" },
2335 { 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* () {
191191 SELECT MAX(created_at) AS "latestUserMessageAt"
192192 FROM projection_thread_messages
193193 WHERE thread_id = ${threadId} AND role = 'user'
194 AND message_id NOT GLOB 'import:*'
194195 `,
195196 });
196197
apps/server/src/persistence/ProviderSessionRuntime.ts+146-5
......@@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
1010import * as SqlSchema from "effect/unstable/sql/SqlSchema";
1111
1212import {
13 AgentSessionImportSource,
1314 IsoDateTime,
1415 ProviderInstanceId,
1516 ProviderSessionRuntimeStatus,
......@@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp
5859export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
5960export 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
6172/**
6273 * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence.
6374 */
......@@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service<
6778 /**
6879 * Insert or replace a provider runtime row.
6980 *
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.
7183 */
7284 readonly upsert: (
7385 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,
7492 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
7593
7694 /**
......@@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({
129147
130148const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
131149
150const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields(
151 Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }),
152);
153
132154function toPersistenceSqlOrDecodeError(
133155 sqlOperation: string,
134156 decodeOperation: string,
......@@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError(
147169export const make = Effect.gen(function* () {
148170 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.
150174 const upsertRuntimeRow = SqlSchema.void({
151175 Request: ProviderSessionRuntimeDbRowSchema,
152176 execute: (runtime) =>
......@@ -171,7 +195,11 @@ export const make = Effect.gen(function* () {
171195 ${runtime.status},
172196 ${runtime.lastSeenAt},
173197 ${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
175203 )
176204 ON CONFLICT (thread_id)
177205 DO UPDATE SET
......@@ -182,7 +210,107 @@ export const make = Effect.gen(function* () {
182210 status = excluded.status,
183211 last_seen_at = excluded.last_seen_at,
184212 resume_cursor_json = excluded.resume_cursor_json,
185 runtime_payload_json = excluded.runtime_payload_json
213 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}
186314 `,
187315 });
188316
......@@ -235,8 +363,8 @@ export const make = Effect.gen(function* () {
235363 `,
236364 });
237365
238 const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) =>
239 upsertRuntimeRow(runtime).pipe(
366 const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) =>
367 (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe(
240368 Effect.mapError(
241369 toPersistenceSqlOrDecodeError(
242370 "ProviderSessionRuntimeRepository.upsert:query",
......@@ -246,6 +374,18 @@ export const make = Effect.gen(function* () {
246374 ),
247375 );
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
249389 const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) =>
250390 getRuntimeRowByThreadId(input).pipe(
251391 Effect.mapError(
......@@ -324,6 +464,7 @@ export const make = Effect.gen(function* () {
324464
325465 return {
326466 upsert,
467 recordImportedTranscript,
327468 getByThreadId,
328469 list,
329470 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) =>
4141 getProjectShellById: (projectId) =>
4242 Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
4343 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
44 getImportedAgentSessionSources: () => Effect.die("unused"),
4445 getThreadCheckpointContext: () => Effect.die("unused"),
4546 getFullThreadDiffContext: () => Effect.die("unused"),
4647 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/provider/Drivers/ClaudeDriver.ts+6-1
......@@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2626import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
2727import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2828import { ServerConfig } from "../../config.ts";
29import { expandHomePath } from "../../pathExpansion.ts";
2930import { ServerSettingsService } from "../../serverSettings.ts";
3031import { ProviderDriverError } from "../Errors.ts";
3132import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
......@@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
119120 driverKind: DRIVER_KIND,
120121 instanceId,
121122 });
122 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
123 const effectiveConfig = {
124 ...config,
125 enabled,
126 binaryPath: expandHomePath(config.binaryPath),
127 } satisfies ClaudeSettings;
123128 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
124129 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
125130 binaryPath: effectiveConfig.binaryPath,
apps/server/src/provider/Drivers/CodexDriver.ts+2
......@@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
3333import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
3434import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
3535import { ServerConfig } from "../../config.ts";
36import { expandHomePath } from "../../pathExpansion.ts";
3637import { ServerSettingsService } from "../../serverSettings.ts";
3738import { ProviderDriverError } from "../Errors.ts";
3839import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
......@@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
157158 const effectiveConfig = {
158159 ...config,
159160 enabled,
161 binaryPath: expandHomePath(config.binaryPath),
160162 homePath: homeLayout.effectiveHomePath ?? "",
161163 } satisfies CodexSettings;
162164 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
apps/server/src/provider/Layers/CodexAdapter.test.ts+1
......@@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea
222222
223223const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
224224 upsert: () => Effect.void,
225 recordImportedTranscript: () => Effect.die("unused"),
225226 getProvider: () =>
226227 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
227228 getBinding: () => Effect.succeed(Option.none()),
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+1
......@@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
516516
517517const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
518518 upsert: () => Effect.void,
519 recordImportedTranscript: () => Effect.die("unused"),
519520 getProvider: () =>
520521 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
521522 getBinding: () => Effect.succeed(Option.none()),
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+132-1
......@@ -24,7 +24,6 @@
2424 */
2525import { describe, expect, it } from "@effect/vitest";
2626import * as NodeServices from "@effect/platform-node/NodeServices";
27import * as Path from "effect/Path";
2827import {
2928 type ClaudeSettings,
3029 type CodexSettings,
......@@ -35,9 +34,12 @@ import {
3534 type ProviderInstanceConfigMap,
3635 ProviderInstanceId,
3736} from "@t3tools/contracts";
37import { isHostWindows } from "@t3tools/shared/hostProcess";
3838import * as DateTime from "effect/DateTime";
3939import * as Effect from "effect/Effect";
40import * as FileSystem from "effect/FileSystem";
4041import * as Layer from "effect/Layer";
42import * as Path from "effect/Path";
4143import * as Stream from "effect/Stream";
4244import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4345
......@@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
4547import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
4648import { AntigravityInstallation } from "../AntigravityInstallation.ts";
4749import { ServerConfig } from "../../config.ts";
50import { expandHomePath } from "../../pathExpansion.ts";
4851import { ServerSettingsService } from "../../serverSettings.ts";
4952import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
5053import { CodexDriver } from "../Drivers/CodexDriver.ts";
......@@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSetti
139142 ...overrides,
140143});
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
142219describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
143220 // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch
144221 // directory. `Layer.merge` just unions requirements, so we have to push
......@@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
261338 }).pipe(Effect.provide(testLayer)),
262339 );
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
264395 it.live(
265396 "shadows instances whose driver is not registered in this build without failing boot",
266397 () =>
apps/server/src/provider/Layers/ProviderService.test.ts+1
......@@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) =>
42714271const boundedListing = makeProviderServiceLayer({
42724272 directory: {
42734273 upsert: () => Effect.void,
4274 recordImportedTranscript: () => Effect.die("unused"),
42744275 getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"),
42754276 getBinding,
42764277 listThreadIds,
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+224-42
......@@ -4,9 +4,13 @@ import * as NodeOS from "node:os";
44import * as NodePath from "node:path";
55
66import * as NodeServices from "@effect/platform-node/NodeServices";
7import { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
8import { it, assert } from "@effect/vitest";
9import { assertSome } from "@effect/vitest/utils";
7import {
8 ProviderDriverKind,
9 ProviderInstanceId,
10 ThreadId,
11 type AgentSessionImportSource,
12} from "@t3tools/contracts";
13import { assert, expect, it } from "@effect/vitest";
1014import * as Effect from "effect/Effect";
1115import * as Layer from "effect/Layer";
1216import * as Option from "effect/Option";
......@@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim
2024import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2125import { 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
2339function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {
2440 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer));
2541 return Layer.mergeAll(
42 persistenceLayer,
2643 runtimeRepositoryLayer,
2744 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
2845 NodeServices.layer,
......@@ -30,7 +47,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
3047}
3148
3249it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {
33 it("upserts and reads thread bindings", () =>
50 it.effect("upserts and reads thread bindings", () =>
3451 Effect.gen(function* () {
3552 const directory = yield* ProviderSessionDirectory;
3653 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
......@@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
3956
4057 yield* directory.upsert({
4158 provider: ProviderDriverKind.make("codex"),
59 providerInstanceId: ProviderInstanceId.make("codex"),
4260 threadId: initialThreadId,
4361 });
4462
4563 const provider = yield* directory.getProvider(initialThreadId);
4664 assert.equal(provider, "codex");
4765 const resolvedBinding = yield* directory.getBinding(initialThreadId);
48 assertSome(resolvedBinding, {
66 expect(Option.getOrThrow(resolvedBinding)).toMatchObject({
4967 threadId: initialThreadId,
5068 provider: ProviderDriverKind.make("codex"),
5169 });
......@@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
5775
5876 yield* directory.upsert({
5977 provider: ProviderDriverKind.make("codex"),
78 providerInstanceId: ProviderInstanceId.make("codex"),
6079 threadId: nextThreadId,
6180 });
6281 const updatedBinding = yield* directory.getBinding(nextThreadId);
......@@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
7493 }
7594
7695 const threadIds = yield* directory.listThreadIds();
77 assert.deepEqual(threadIds, [nextThreadId]);
78 }));
96 expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId]));
97 }),
98 );
7999
80 it("persists runtime fields and merges payload updates", () =>
100 it.effect("persists runtime fields and merges payload updates", () =>
81101 Effect.gen(function* () {
82102 const directory = yield* ProviderSessionDirectory;
83103 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
......@@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
86106
87107 yield* directory.upsert({
88108 provider: ProviderDriverKind.make("codex"),
109 providerInstanceId: ProviderInstanceId.make("codex"),
89110 threadId,
90111 status: "starting",
91112 resumeCursor: {
......@@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
99120
100121 yield* directory.upsert({
101122 provider: ProviderDriverKind.make("codex"),
123 providerInstanceId: ProviderInstanceId.make("codex"),
102124 threadId,
103125 status: "running",
104126 runtimePayload: {
......@@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
120142 activeTurnId: "turn-1",
121143 });
122144 }
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", () =>
126297 Effect.gen(function* () {
127298 const directory = yield* ProviderSessionDirectory;
128299 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
......@@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
162333 },
163334 });
164335
165 const bindings = yield* directory.listBindings();
336 const bindings = (yield* directory.listBindings()).filter(
337 (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId,
338 );
166339
167340 assert.deepEqual(bindings, [
168341 {
169342 threadId: olderThreadId,
170343 provider: ProviderDriverKind.make("claudeAgent"),
344 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
171345 adapterKey: "claudeAgent",
172346 runtimeMode: "approval-required",
173347 status: "starting",
......@@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
182356 {
183357 threadId: newerThreadId,
184358 provider: ProviderDriverKind.make("codex"),
359 providerInstanceId: ProviderInstanceId.make("codex"),
185360 adapterKey: "codex",
186361 runtimeMode: "full-access",
187362 status: "running",
......@@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
194369 },
195370 },
196371 ]);
197 }));
372 }),
373 );
198374
199 it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () =>
200 Effect.gen(function* () {
201 const directory = yield* ProviderSessionDirectory;
202 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
203 const threadId = ThreadId.make("thread-provider-change");
375 it.effect(
376 "resets adapterKey to the new provider when provider changes without an explicit adapter key",
377 () =>
378 Effect.gen(function* () {
379 const directory = yield* ProviderSessionDirectory;
380 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
381 const threadId = ThreadId.make("thread-provider-change");
204382
205 yield* runtimeRepository.upsert({
206 threadId,
207 providerName: "claudeAgent",
208 providerInstanceId: null,
209 adapterKey: "claudeAgent",
210 runtimeMode: "full-access",
211 status: "running",
212 lastSeenAt: "2026-01-01T00:00:00.000Z",
213 resumeCursor: null,
214 runtimePayload: null,
215 });
383 yield* runtimeRepository.upsert({
384 threadId,
385 providerName: "claudeAgent",
386 providerInstanceId: null,
387 adapterKey: "claudeAgent",
388 runtimeMode: "full-access",
389 status: "running",
390 lastSeenAt: "2026-01-01T00:00:00.000Z",
391 resumeCursor: null,
392 runtimePayload: null,
393 });
216394
217 yield* directory.upsert({
218 provider: ProviderDriverKind.make("codex"),
219 threadId,
220 });
395 yield* directory.upsert({
396 provider: ProviderDriverKind.make("codex"),
397 providerInstanceId: ProviderInstanceId.make("codex"),
398 threadId,
399 });
221400
222 const runtime = yield* runtimeRepository.getByThreadId({ threadId });
223 assert.equal(Option.isSome(runtime), true);
224 if (Option.isSome(runtime)) {
225 assert.equal(runtime.value.providerName, "codex");
226 assert.equal(runtime.value.adapterKey, "codex");
227 }
228 }));
401 const runtime = yield* runtimeRepository.getByThreadId({ threadId });
402 assert.equal(Option.isSome(runtime), true);
403 if (Option.isSome(runtime)) {
404 assert.equal(runtime.value.providerName, "codex");
405 assert.equal(runtime.value.adapterKey, "codex");
406 }
407 }),
408 );
229409
230 it("rehydrates persisted mappings across layer restart", () =>
410 it.effect("rehydrates persisted mappings across layer restart", () =>
231411 Effect.gen(function* () {
232412 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-"));
233413 const dbPath = NodePath.join(tempDir, "orchestration.sqlite");
......@@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
239419 const directory = yield* ProviderSessionDirectory;
240420 yield* directory.upsert({
241421 provider: ProviderDriverKind.make("codex"),
422 providerInstanceId: ProviderInstanceId.make("codex"),
242423 threadId,
243424 });
244425 }).pipe(Effect.provide(directoryLayer));
......@@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
250431 assert.equal(provider, "codex");
251432
252433 const resolvedBinding = yield* directory.getBinding(threadId);
253 assertSome(resolvedBinding, {
434 expect(Option.getOrThrow(resolvedBinding)).toMatchObject({
254435 threadId,
255436 provider: ProviderDriverKind.make("codex"),
256437 });
......@@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
267448 }).pipe(Effect.provide(directoryLayer));
268449
269450 NodeFS.rmSync(tempDir, { recursive: true, force: true });
270 }));
451 }),
452 );
271453});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+35-20
......@@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
100100 ),
101101 );
102102
103 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {
103 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) {
104104 const existing = yield* repository
105105 .getByThreadId({ threadId: binding.threadId })
106106 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")));
......@@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
126126 });
127127 }
128128 yield* repository
129 .upsert({
130 threadId: resolvedThreadId,
131 providerName: binding.provider,
132 providerInstanceId,
133 adapterKey:
134 binding.adapterKey ??
135 (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)),
136 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
137 status: binding.status ?? existingRuntime?.status ?? "running",
138 lastSeenAt: now,
139 resumeCursor:
140 binding.resumeCursor !== undefined
141 ? binding.resumeCursor
142 : (existingRuntime?.resumeCursor ?? null),
143 runtimePayload: mergeRuntimePayload(
144 existingRuntime?.runtimePayload ?? null,
145 binding.runtimePayload,
146 ),
147 })
129 .upsert(
130 {
131 threadId: resolvedThreadId,
132 providerName: binding.provider,
133 providerInstanceId,
134 adapterKey:
135 binding.adapterKey ??
136 (providerChanged
137 ? binding.provider
138 : (existingRuntime?.adapterKey ?? binding.provider)),
139 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
140 status: binding.status ?? existingRuntime?.status ?? "running",
141 lastSeenAt: now,
142 resumeCursor:
143 binding.resumeCursor !== undefined
144 ? binding.resumeCursor
145 : (existingRuntime?.resumeCursor ?? null),
146 runtimePayload: mergeRuntimePayload(
147 existingRuntime?.runtimePayload ?? null,
148 binding.runtimePayload,
149 ),
150 },
151 options,
152 )
148153 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
149154 });
150155
......@@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
164169 ),
165170 );
166171
172 const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = (
173 input,
174 ) =>
175 repository
176 .recordImportedTranscript(input)
177 .pipe(
178 Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")),
179 );
180
167181 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
168182 repository.list().pipe(
169183 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")),
......@@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
184198
185199 return {
186200 upsert,
201 recordImportedTranscript,
187202 getProvider,
188203 getBinding,
189204 listThreadIds,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => {
217217 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
218218 getProjectShellById: () => Effect.die("unused"),
219219 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
220 getImportedAgentSessionSources: () => Effect.die("unused"),
220221 getThreadCheckpointContext: () => Effect.die("unused"),
221222 getFullThreadDiffContext: () => Effect.die("unused"),
222223 getThreadRuntimeContext: () => Effect.die("unused"),
apps/server/src/provider/ProviderInstanceEnvironment.test.ts+48-1
......@@ -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
38import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
49
510describe("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
653 it("overrides inherited environment values and preserves empty strings", () => {
754 expect(
855 mergeProviderInstanceEnvironment(
apps/server/src/provider/ProviderInstanceEnvironment.ts+7-1
......@@ -1,5 +1,7 @@
11import type { ProviderInstanceEnvironment } from "@t3tools/contracts";
22
3import { expandHomePath } from "../pathExpansion.ts";
4
35export function mergeProviderInstanceEnvironment(
46 environment: ProviderInstanceEnvironment | undefined,
57 baseEnv: NodeJS.ProcessEnv = process.env,
......@@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment(
1012
1113 const next: NodeJS.ProcessEnv = { ...baseEnv };
1214 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;
1420 }
1521 return next;
1622}
apps/server/src/provider/Services/ProviderSessionDirectory.ts+12
......@@ -1,4 +1,5 @@
11import type {
2 AgentSessionImportSource,
23 ProviderInstanceId,
34 ProviderDriverKind,
45 ProviderSessionRuntimeStatus,
......@@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError =
4041 | ProviderValidationError
4142 | ProviderSessionDirectoryPersistenceError;
4243
44export interface ProviderSessionDirectoryUpsertOptions {
45 readonly onConflict?: "update" | "ignore";
46}
47
4348export interface ProviderSessionDirectoryShape {
4449 readonly upsert: (
4550 binding: ProviderRuntimeBinding,
51 options?: ProviderSessionDirectoryUpsertOptions,
4652 ) => 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
4860 readonly getProvider: (
4961 threadId: ThreadId,
5062 ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>;
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+8
......@@ -57,6 +57,14 @@ rl.on("line", (line) => {
5757 });
5858 return;
5959 }
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 }
6068 if (method === "thread/start") {
6169 write({ id, result: fixture.responses.threadStart });
6270 return;
apps/server/src/relay/AgentAwarenessRelay.test.ts+87-11
......@@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
138138 expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId);
139139 });
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", () => {
142142 const now = "2026-05-25T00:00:00.000Z";
143143 const base = {
144144 sequence: 1,
......@@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
147147 aggregateKind: "thread",
148148 aggregateId: "thread-1" as ThreadId,
149149 occurredAt: now,
150 metadata: {},
150151 };
151152
152153 expect(
......@@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
202203 },
203204 } as unknown as OrchestrationEvent),
204205 ).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);
205236 });
206237
207238 it("deduplicates awareness state updates whose only change is their event timestamp", () => {
......@@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
400431 }),
401432 );
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", () =>
404435 Effect.scoped(
405436 Effect.gen(function* () {
406437 const events = yield* Queue.unbounded<OrchestrationEvent>();
407438 const threadShellRequested = yield* Deferred.make<void>();
439 const releaseThreadShell = yield* Deferred.make<void>();
440 const threadShellRequests: Array<ThreadId> = [];
441 let fetchCallCount = 0;
408442 const secrets = makeMemorySecretStore();
409443 const now = "2026-05-25T00:00:00.000Z";
410444 const projectId = "project-1" as ProjectId;
411445 const threadId = "thread-1" as ThreadId;
446 const importedThreadId = "import:codex:session-1" as ThreadId;
412447 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
414460 const project = {
415461 id: projectId,
416462 title: "T3 Code",
......@@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
473519 getShellSnapshot: () =>
474520 Effect.succeed({
475521 snapshotSequence: 1,
476 projects: [project],
477 threads: [thread],
522 projects: [],
523 threads: [],
478524 updatedAt: now,
479525 } satisfies OrchestrationShellSnapshot),
480 getThreadShellById: () =>
481 Deferred.succeed(threadShellRequested, undefined).pipe(
482 Effect.ignore,
483 Effect.as(Option.some(thread)),
484 ),
526 getThreadShellById: (requestedThreadId: ThreadId) =>
527 Effect.gen(function* () {
528 threadShellRequests.push(requestedThreadId);
529 if (requestedThreadId !== threadId) return Option.none();
530 yield* Deferred.succeed(threadShellRequested, undefined);
531 yield* Deferred.await(releaseThreadShell);
532 return Option.some(thread);
533 }),
485534 getProjectShellById: () => Effect.succeed(Option.some(project)),
486535 } as unknown as ProjectionSnapshotQueryShape;
487536
......@@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
511560 yield* Effect.gen(function* () {
512561 const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
513562 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");
515564 yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential");
516565 yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true");
517566 yield* Queue.offer(events, {
518 type: "thread.activity-appended",
567 type: "thread.created",
519568 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,
520591 eventId: "evt-1",
521592 commandId: CommandId.make("cmd-1"),
522593 aggregateKind: "thread",
523594 aggregateId: threadId,
524595 actor: { kind: "server" },
596 metadata: {},
525597 payload: {
526598 threadId,
527599 activity: {
......@@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
532604 } as unknown as OrchestrationEvent);
533605
534606 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);
535610 }).pipe(
536611 Effect.provide(
537612 AgentAwarenessRelay.layer.pipe(
......@@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
692767 aggregateKind: "thread",
693768 aggregateId: threadId,
694769 actor: { kind: "server" },
770 metadata: {},
695771 payload: {
696772 threadId,
697773 activity: {
apps/server/src/relay/AgentAwarenessRelay.ts+3
......@@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null {
6767}
6868
6969export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean {
70 if (event.metadata.historyImport === true) {
71 return false;
72 }
7073 switch (event.type) {
7174 case "thread.message-sent":
7275 case "thread.turn-start-requested":
apps/server/src/server.test.ts+203
......@@ -36,6 +36,7 @@ import {
3636 type ProviderInstallState,
3737 ProviderSetupError,
3838 ResolvedKeybindingRule,
39 type ServerLifecycleStreamEvent,
3940 ThreadId,
4041 TurnId,
4142 WS_METHODS,
......@@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(
9192const decodeTransferShellSnapshot = Schema.decodeUnknownEffect(
9293 Schema.fromJsonString(OrchestrationShellSnapshot),
9394);
95const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
9496
9597import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
9698import * as ServerConfig from "./config.ts";
......@@ -127,6 +129,7 @@ import {
127129 AntigravityInstallationError,
128130} from "./provider/AntigravityInstallation.ts";
129131import type { ProviderInstance } from "./provider/ProviderDriver.ts";
132import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
130133import { ProviderAdapterRequestError } from "./provider/Errors.ts";
131134import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
132135import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
......@@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: {
513516 projectSetupScriptRunner?: Partial<
514517 ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]
515518 >;
519 providerSessionDirectory?: Partial<
520 ProviderSessionDirectory.ProviderSessionDirectory["Service"]
521 >;
516522 terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>;
517523 orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>;
518524 threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>;
......@@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: {
785791 managedDirectory: "unused-test-antigravity-runtime",
786792 ...options?.layers?.antigravityInstallation,
787793 }),
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 }),
788801 ),
789802 ),
790803 Layer.provide(
......@@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: {
973986 }),
974987 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
975988 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
989 getImportedAgentSessionSources: () => Effect.succeed([]),
976990 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
977991 ...options?.layers?.projectionSnapshotQuery,
978992 }),
......@@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
53325346 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
53335347 );
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
53355446 it.effect("uploads Codex thread feedback through websocket rpc", () =>
53365447 Effect.gen(function* () {
53375448 const input = {
......@@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
62396350 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
62406351 );
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
62426445 it.effect("routes websocket rpc projects.searchEntries", () =>
62436446 Effect.gen(function* () {
62446447 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", () =
151151 ),
152152 ),
153153 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
154 recordImportedTranscript: () => Effect.die("unused"),
154155 getProvider: () => Effect.die("unused"),
155156 listThreadIds: () => Effect.die("unused"),
156157 listBindings: () => Effect.succeed([]),
......@@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)(
265266 firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void,
266267 ),
267268 ),
269 recordImportedTranscript: () => Effect.die("unused"),
268270 getProvider: () => Effect.die("unused"),
269271 listThreadIds: () => Effect.die("unused"),
270272 listBindings: () => Effect.succeed([]),
......@@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => {
391393 );
392394 },
393395 upsert: () => Effect.void,
396 recordImportedTranscript: () => Effect.die("unused"),
394397 getProvider: () => Effect.die("unused"),
395398 listThreadIds: () => Effect.die("unused"),
396399 listBindings: () => Effect.succeed([]),
......@@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure
446449 }),
447450 ),
448451 upsert: () => Effect.void,
452 recordImportedTranscript: () => Effect.die("unused"),
449453 getProvider: () => Effect.die("unused"),
450454 listThreadIds: () => Effect.die("unused"),
451455 listBindings: () => Effect.succeed([]),
......@@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
517521 ),
518522 ),
519523 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
524 recordImportedTranscript: () => Effect.die("unused"),
520525 getProvider: () => Effect.die("unused"),
521526 listThreadIds: () => Effect.die("unused"),
522527 listBindings: () => Effect.succeed([]),
......@@ -596,6 +601,7 @@ it.effect(
596601 }),
597602 ),
598603 upsert: () => Effect.fail(writeFailure),
604 recordImportedTranscript: () => Effect.die("unused"),
599605 getProvider: () => Effect.die("unused"),
600606 listThreadIds: () => Effect.die("unused"),
601607 listBindings: () => Effect.succeed([]),
......@@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure",
633639 directory: {
634640 getBinding: () => Effect.succeed(Option.none()),
635641 upsert: () => Effect.void,
642 recordImportedTranscript: () => Effect.die("unused"),
636643 getProvider: () => Effect.die("unused"),
637644 listThreadIds: () => Effect.die("unused"),
638645 listBindings: () => Effect.succeed([]),
......@@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot
682689 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, {
683690 getBinding: () => Effect.die("unused"),
684691 upsert: () => Effect.die("unused"),
692 recordImportedTranscript: () => Effect.die("unused"),
685693 getProvider: () => Effect.die("unused"),
686694 listThreadIds: () => Effect.die("unused"),
687695 listBindings: () => Effect.succeed([]),
......@@ -754,6 +762,7 @@ for (const scenario of [
754762 Effect.sync(() => {
755763 upserts.push(binding);
756764 }),
765 recordImportedTranscript: () => Effect.die("unused"),
757766 getProvider: () => Effect.die("unused"),
758767 listThreadIds: () => Effect.die("unused"),
759768 listBindings: () => Effect.succeed([]),
......@@ -827,6 +836,7 @@ for (const preparedStatus of [
827836 if (binding.status !== "starting" || sends.length === 0) return;
828837 yield* Deferred.succeed(cleared, undefined);
829838 }),
839 recordImportedTranscript: () => Effect.die("unused"),
830840 getProvider: () => Effect.die("unused"),
831841 listThreadIds: () => Effect.die("unused"),
832842 listBindings: () =>
......@@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", (
932942 Effect.sync(() => {
933943 binding = next;
934944 }),
945 recordImportedTranscript: () => Effect.die("unused"),
935946 getProvider: () => Effect.die("unused"),
936947 listThreadIds: () => Effect.die("unused"),
937948 listBindings: () => Effect.succeed([]),
apps/server/src/serverRuntimeStartup.test.ts+89
......@@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
155155 ),
156156 getProjectShellById: () => Effect.die("unused"),
157157 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)),
158 getImportedAgentSessionSources: () => Effect.die("unused"),
158159 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
159160 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
160161 getThreadRuntimeContext: () => Effect.die("unused"),
......@@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
181182 assert.deepStrictEqual(targets, {
182183 bootstrapProjectId,
183184 bootstrapThreadId,
185 bootstrapProjectCreated: false,
186 bootstrapThreadCreated: false,
184187 });
185188 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
186189 });
......@@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
212215 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
213216 getProjectShellById: () => Effect.die("unused"),
214217 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
218 getImportedAgentSessionSources: () => Effect.die("unused"),
215219 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
216220 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
217221 getThreadRuntimeContext: () => Effect.die("unused"),
......@@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
237241
238242 assert.equal(typeof targets.bootstrapProjectId, "string");
239243 assert.equal(typeof targets.bootstrapThreadId, "string");
244 assert.equal(targets.bootstrapProjectCreated, true);
245 assert.equal(targets.bootstrapThreadCreated, true);
240246 const commands = yield* Ref.get(dispatchCalls);
241247 assert.deepStrictEqual(
242248 commands.map((command) => command.type),
......@@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
250256 }),
251257);
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
253313it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () =>
254314 Effect.gen(function* () {
255315 const crypto = yield* Crypto.Crypto;
......@@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
278338 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
279339 getProjectShellById: () => Effect.die("unused"),
280340 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
341 getImportedAgentSessionSources: () => Effect.die("unused"),
281342 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
282343 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
283344 getThreadRuntimeContext: () => Effect.die("unused"),
......@@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
309370 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
310371 }).pipe(Effect.provide(NodeServices.layer)),
311372);
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* () {
194194
195195 let bootstrapProjectId: ProjectId | undefined;
196196 let bootstrapThreadId: ThreadId | undefined;
197 let bootstrapProjectCreated = false;
198 let bootstrapThreadCreated = false;
197199
198200 if (serverConfig.autoBootstrapProjectFromCwd) {
199201 yield* Effect.gen(function* () {
......@@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
216218 workspaceRoot: serverConfig.cwd,
217219 createdAt,
218220 });
221 bootstrapProjectId = nextProjectId;
222 bootstrapProjectCreated = true;
219223 } else {
220224 nextProjectId = existingProject.value.id;
225 bootstrapProjectId = nextProjectId;
221226 nextThreadModelSelection =
222227 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
223228 }
224229
225 const existingThreadId =
226 yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId);
227 if (Option.isNone(existingThreadId)) {
228 const createdAt = DateTime.formatIso(yield* DateTime.now);
229 const createdThreadId = ThreadId.make(yield* randomUUID);
230 yield* orchestrationEngine.dispatch({
231 type: "thread.create",
232 commandId: CommandId.make(yield* randomUUID),
233 threadId: createdThreadId,
234 projectId: nextProjectId,
235 title: "New thread",
236 modelSelection: nextThreadModelSelection,
237 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
238 runtimeMode: "full-access",
239 branch: null,
240 worktreePath: null,
241 createdAt,
242 });
243 bootstrapProjectId = nextProjectId;
244 bootstrapThreadId = createdThreadId;
245 } else {
246 bootstrapProjectId = nextProjectId;
247 bootstrapThreadId = existingThreadId.value;
248 }
230 yield* Effect.gen(function* () {
231 const existingThreadId =
232 yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId);
233 if (Option.isNone(existingThreadId)) {
234 const createdAt = DateTime.formatIso(yield* DateTime.now);
235 const createdThreadId = ThreadId.make(yield* randomUUID);
236 yield* orchestrationEngine.dispatch({
237 type: "thread.create",
238 commandId: CommandId.make(yield* randomUUID),
239 threadId: createdThreadId,
240 projectId: nextProjectId,
241 title: "New thread",
242 modelSelection: nextThreadModelSelection,
243 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
244 runtimeMode: "full-access",
245 branch: null,
246 worktreePath: null,
247 createdAt,
248 });
249 bootstrapThreadId = createdThreadId;
250 bootstrapThreadCreated = true;
251 } else {
252 bootstrapThreadId = existingThreadId.value;
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 );
249264 });
250265 }
251266
252267 return {
253268 ...(bootstrapProjectId ? { bootstrapProjectId } : {}),
254269 ...(bootstrapThreadId ? { bootstrapThreadId } : {}),
270 ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}),
271 ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}),
255272 } as const;
256273});
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
258294const resolveStartupBrowserTarget = Effect.gen(function* () {
259295 const serverConfig = yield* ServerConfig.ServerConfig;
260296 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
......@@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) =>
839875 runStartupPhase(
840876 "welcome.autobootstrap",
841877 Effect.gen(function* () {
842 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
843 Effect.provideService(Crypto.Crypto, crypto),
878 const bootstrapCompletion = yield* completeAutoBootstrapWelcome(
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 },
844892 );
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 });
856893 yield* lifecycleEvents.publish({
857894 version: 1,
858895 type: "welcome",
859896 payload: {
860897 environment,
861898 ...welcomeBase,
862 ...bootstrapTargets,
899 ...bootstrapCompletion,
863900 },
864901 });
865 }).pipe(
866 Effect.catch((cause) =>
867 Effect.logWarning("startup auto-bootstrap welcome failed", {
868 cause,
869 }),
870 ),
871 ),
902 }).pipe(Effect.ignoreCause({ log: true })),
872903 ),
873904 );
874905 }
......@@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) =>
914945 lifecycleEvents.publish({
915946 version: 1,
916947 type: "welcome",
917 payload: { environment, ...welcomeBase },
948 payload: {
949 environment,
950 ...welcomeBase,
951 bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete",
952 },
918953 }),
919954 );
920955 yield* options?.activate ?? Effect.void;
apps/server/src/serverSettings.test.ts+37
......@@ -14,6 +14,7 @@ import * as Duration from "effect/Duration";
1414import * as FileSystem from "effect/FileSystem";
1515import * as Layer from "effect/Layer";
1616import * as Option from "effect/Option";
17import * as Path from "effect/Path";
1718import * as PlatformError from "effect/PlatformError";
1819import * as Schema from "effect/Schema";
1920import * as Stream from "effect/Stream";
......@@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
2223import * as ServerConfig from "./config.ts";
2324import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
2425import * as ServerSettingsModule from "./serverSettings.ts";
26import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
2527
2628const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
2729const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
......@@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => {
11021104 );
11031105 }).pipe(Effect.provide(makeServerSettingsLayer())),
11041106 );
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 );
11051142});
apps/server/src/terminal/Manager.test.ts+412
......@@ -7,9 +7,14 @@ import {
77 type TerminalMetadataStreamEvent,
88 type TerminalOpenInput,
99 type TerminalRestartInput,
10 ProviderDriverKind,
11 ProviderInstanceId,
12 ServerSettingsError,
13 TerminalProviderInstanceNotFoundError,
1014} from "@t3tools/contracts";
1115import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1216import * as Data from "effect/Data";
17import * as Deferred from "effect/Deferred";
1318import * as Duration from "effect/Duration";
1419import * as Effect from "effect/Effect";
1520import * as Encoding from "effect/Encoding";
......@@ -23,11 +28,16 @@ import * as Path from "effect/Path";
2328import * as Ref from "effect/Ref";
2429import * as Schedule from "effect/Schedule";
2530import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
2632import * as TestClock from "effect/testing/TestClock";
2733import { ChildProcessSpawner } from "effect/unstable/process";
2834import { 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";
3039import * as ProcessRunner from "../processRunner.ts";
40import * as ServerSettings from "../serverSettings.ts";
3141import * as TerminalManager from "./Manager.ts";
3242import * as PtyAdapter from "./PtyAdapter.ts";
3343
......@@ -215,6 +225,9 @@ interface CreateManagerOptions {
215225 maxRetainedInactiveSessions?: number;
216226 historyByteLimit?: number;
217227 ptyAdapter?: FakePtyAdapter;
228 resolveProviderInstanceEnvironment?: Parameters<
229 typeof TerminalManager.makeWithOptions
230 >[0]["resolveProviderInstanceEnvironment"];
218231}
219232
220233interface ManagerFixture {
......@@ -259,6 +272,9 @@ const createManager = (
259272 ...(options.maxRetainedInactiveSessions !== undefined
260273 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
261274 : {}),
275 ...(options.resolveProviderInstanceEnvironment !== undefined
276 ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment }
277 : {}),
262278 });
263279 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
264280 const unsubscribe = yield* manager.subscribe((event) =>
......@@ -1736,6 +1752,26 @@ it.layer(
17361752 }),
17371753 );
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
17391775 it.effect("strips AppImage runtime env from terminal sessions", () =>
17401776 Effect.gen(function* () {
17411777 const appDir = "/tmp/.mount_T3Codeabc123";
......@@ -1822,6 +1858,382 @@ it.layer(
18221858 }),
18231859 );
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
18252237 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
18262238 Effect.gen(function* () {
18272239 if ((yield* HostProcessPlatform) === "win32") return;
apps/server/src/terminal/Manager.ts+182-79
......@@ -15,6 +15,8 @@ import {
1515 TerminalError,
1616 TerminalHistoryError,
1717 TerminalNotRunningError,
18 TerminalProviderInstanceNotFoundError,
19 TerminalProviderEnvironmentError,
1820 TerminalResizeError,
1921 TerminalSessionLookupError,
2022 TerminalWriteError,
......@@ -31,6 +33,9 @@ import {
3133 type TerminalSessionStatus,
3234 type TerminalSummary,
3335 type TerminalWriteInput,
36 ClaudeSettings,
37 CodexSettings,
38 ProviderInstanceId,
3439} from "@t3tools/contracts";
3540import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
3641import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
......@@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore";
5257import * as SynchronizedRef from "effect/SynchronizedRef";
5358
5459import * 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";
5565import {
5666 increment,
5767 terminalRestartsTotal,
5868 terminalSessionsTotal,
5969} from "../observability/Metrics.ts";
70import { expandHomePath } from "../pathExpansion.ts";
6071import * as ProcessRunner from "../processRunner.ts";
6172import * as PortScanner from "../preview/PortScanner.ts";
6273import * as PtyAdapter from "./PtyAdapter.ts";
......@@ -69,6 +80,8 @@ export {
6980 TerminalError,
7081 TerminalHistoryError,
7182 TerminalNotRunningError,
83 TerminalProviderInstanceNotFoundError,
84 TerminalProviderEnvironmentError,
7285 TerminalResizeError,
7386 TerminalSessionLookupError,
7487 TerminalWriteError,
......@@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30;
8699const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
87100const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
88101const MAX_TERMINAL_LABEL_LENGTH = 128;
102const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
103const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
89104
90105class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
91106 "TerminalSubprocessCheckError",
......@@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv(
12671282 }
12681283 if (runtimeEnv) {
12691284 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;
12711287 }
12721288 }
12731289 // Both PTY backends feed truecolor-capable terminal clients.
......@@ -1306,17 +1322,78 @@ interface TerminalManagerOptions {
13061322 readonly threadId: string;
13071323 readonly terminalId: string;
13081324 }) => 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 >;
13091332}
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
13111375export const make = Effect.fn("TerminalManager.make")(function* () {
13121376 const { terminalLogsDir } = yield* ServerConfig.ServerConfig;
13131377 const ptyAdapter = yield* PtyAdapter.PtyAdapter;
13141378 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 );
13151391 return yield* makeWithOptions({
13161392 logsDir: terminalLogsDir,
13171393 ptyAdapter,
13181394 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
13191395 unregisterTerminal: portDiscovery.unregisterTerminal,
1396 resolveProviderInstanceEnvironment,
13201397 });
13211398});
13221399
......@@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
13391416 const baseEnv = options.env ?? process.env;
13401417 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
13411418 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 );
13421437 // One process-table snapshot per poll tick, shared across every terminal.
13431438 // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
13441439 // can exhaust the PID space on hosts with many sessions (#6332).
......@@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
24682563 });
24692564
24702565 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
24732571 const openOrAttachForStream = (input: TerminalAttachInput) =>
24742572 withThreadLock(
......@@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
24852583 });
24862584 }
24872585
2488 return yield* openLocked({
2586 const resolvedInput = yield* resolveLaunchInputEnvironment({
24892587 ...input,
24902588 terminalId,
24912589 cwd: input.cwd,
24922590 });
2591 return yield* openLocked(resolvedInput);
24932592 }
24942593
24952594 const session = existing.value;
......@@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
24972596 const targetRows = input.rows ?? session.rows;
24982597
24992598 if (!session.process && input.cwd && input.restartIfNotRunning === true) {
2500 return yield* openLocked({
2599 const resolvedInput = yield* resolveLaunchInputEnvironment({
25012600 ...input,
25022601 terminalId,
25032602 cwd: input.cwd,
25042603 });
2604 return yield* openLocked(resolvedInput);
25052605 }
25062606
25072607 if (
......@@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
27532853 }),
27542854 );
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
27562933 const restart: TerminalManager["Service"]["restart"] = (input) =>
27572934 withThreadLock(
27582935 input.threadId,
2759 Effect.gen(function* () {
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 }),
2936 resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)),
28342937 );
28352938
28362939 const close: TerminalManager["Service"]["close"] = (input) =>
apps/server/src/ws.ts+40-1
......@@ -53,6 +53,7 @@ import {
5353 type RelayClientInstallProgressEvent,
5454 ServerSelfUpdateError,
5555 type ServerSelfUpdateProgressEvent,
56 type ServerLifecycleStreamEvent,
5657 type FilesystemBrowseFailure,
5758 FilesystemBrowseError,
5859 AssetWorkspaceContextNotFoundError,
......@@ -96,6 +97,7 @@ import {
9697} from "./observability/RpcInstrumentation.ts";
9798import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
9899import * as ProviderService from "./provider/Services/ProviderService.ts";
100import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
99101import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
100102import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";
101103import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
......@@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
119121import * as GitWorkflowService from "./git/GitWorkflowService.ts";
120122import * as ReviewService from "./review/ReviewService.ts";
121123import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
124import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
125import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";
122126import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
123127import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
124128import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
......@@ -512,6 +516,7 @@ const makeWsRpcLayer = (
512516 const portDiscovery = yield* PortScanner.PortDiscovery;
513517 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
514518 const providerService = yield* ProviderService.ProviderService;
519 const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
515520 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
516521 const providerAuth = yield* ProviderAuthService;
517522 const providerInstances = yield* ProviderInstanceRegistry;
......@@ -560,6 +565,7 @@ const makeWsRpcLayer = (
560565 return true;
561566 });
562567 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
568 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
563569 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
564570 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
565571 const rpcClientIds = yield* Ref.make(new Set<RpcClientId>());
......@@ -2332,6 +2338,31 @@ const makeWsRpcLayer = (
23322338 deletePendingAttachment(input.attachmentId),
23332339 { "rpc.aggregate": "workspace" },
23342340 ),
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 ),
23352366 [WS_METHODS.assetsCreateUrl]: (input) =>
23362367 observeRpcEffect(
23372368 WS_METHODS.assetsCreateUrl,
......@@ -2746,11 +2777,18 @@ const makeWsRpcLayer = (
27462777 observeRpcStreamEffect(
27472778 WS_METHODS.subscribeServerLifecycle,
27482779 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 );
27492787 const snapshot = yield* lifecycleEvents.snapshot;
27502788 const snapshotEvents = Array.from(snapshot.events).toSorted(
27512789 (left, right) => left.sequence - right.sequence,
27522790 );
2753 const liveEvents = lifecycleEvents.stream.pipe(
2791 const liveEvents = Stream.fromQueue(liveBuffer).pipe(
27542792 Stream.filter((event) => event.sequence > snapshot.sequence),
27552793 );
27562794 return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
......@@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
28772915 previewAutomationBroker,
28782916 ).pipe(
28792917 Layer.provideMerge(RpcSerialization.layerJson),
2918 Layer.provide(AgentSessionScanner.layer),
28802919 Layer.provide(ProviderMaintenanceRunner.layer),
28812920 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
28822921 // 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", () => {
310310 expect(testApi.calls.session).toBe(2);
311311 });
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
313370 it("rejects a blank pairing token with a structured validation error", async () => {
314371 const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } =
315372 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";
66
77import { previewBridge } from "~/components/preview/previewBridge";
88import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { useClientSettingsHydrated } from "~/hooks/useSettings";
910import { cn, isMacPlatform } from "~/lib/utils";
1011
1112import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
......@@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: {
6667 zoomFactor,
6768 profileId,
6869 } = props;
70 const clientSettingsHydrated = useClientSettingsHydrated();
6971 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
7072 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
7173 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
......@@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: {
9496 usePreviewBridge({ threadRef, tabId, runtimeTabId });
9597
9698 useEffect(() => {
99 if (!clientSettingsHydrated) return;
97100 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
98101 const lease = acquireDesktopTab(runtimeTabId);
99102 tabLeaseRef.current = lease;
......@@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: {
101104 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
102105 lease.release();
103106 };
104 }, [runtimeTabId]);
107 }, [clientSettingsHydrated, runtimeTabId]);
105108
106109 const [webviewGeneration, setWebviewGeneration] = useState(0);
107110 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
......@@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: {
118121 useEffect(() => {
119122 const webview = webviewRef.current;
120123 const bridge = previewBridge;
121 if (!webview || !config || !bridge) return;
124 if (!clientSettingsHydrated || !webview || !config || !bridge) return;
122125 let disposed = false;
123126 let recoveryTimeout: ReturnType<typeof setTimeout> | null = null;
124127 const register = () => {
......@@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: {
164167 webview.removeEventListener("dom-ready", register);
165168 webview.removeEventListener("render-process-gone", recoverGuest);
166169 };
167 }, [config, initialSrc, runtimeTabId, webviewGeneration]);
170 }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]);
168171
169172 const active = presentation.visible && presentation.rect !== null;
170173 const lastRect = presentation.rect;
......@@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: {
249252 wrapper.scrollTo({ left: 0, top: 0 });
250253 }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);
251254
252 if (!config) return null;
255 if (!clientSettingsHydrated || !config) return null;
253256
254257 const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
255258 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
apps/web/src/browser/browserDefaults.test.ts+23-2
......@@ -1,15 +1,17 @@
11import { describe, expect, it, vi } from "vite-plus/test";
22import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
33
4import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
5
46const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
57
68vi.mock("~/hooks/useSettings", () => ({
79 getClientSettings: () => settings.current,
810 useClientSettings: () => undefined,
9 ensureClientSettingsHydrated: () => Promise.resolve(),
11 ensureClientSettingsHydrated: vi.fn(async () => undefined),
1012}));
1113
12const { getBrowserDefaults } = await import("./browserDefaults");
14const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults");
1315
1416const withDefaultProfile = (browserDefaultProfileId: string) => {
1517 settings.current = {
......@@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => {
4143 );
4244 });
4345});
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 {
7979 * Opening a preview is asynchronous anyway, and before hydration the snapshot
8080 * is the schema defaults rather than the user's — a tab opened in that window
8181 * 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.
8283 */
8384export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {
8485 await ensureClientSettingsHydrated();
apps/web/src/browser/browserLinkTarget.test.ts+26-2
......@@ -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
515const click = { metaKey: false, ctrlKey: false };
616
......@@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => {
6777 }
6878 });
6979});
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 {
5555 * hydration the snapshot is the schema default ("system"), so a link clicked
5656 * in the first moments after launch would ignore a persisted "app" — opening
5757 * is asynchronous anyway, so waiting costs nothing the user can see.
58 * Read failures reject rather than choosing a browser without the saved preference.
5859 */
5960export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> {
6061 await ensureClientSettingsHydrated();
apps/web/src/browser/browserRecording.test.ts+31
......@@ -5,6 +5,8 @@ import {
55} from "@t3tools/contracts";
66import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
77
8import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
9
810const {
911 clientSettings,
1012 events,
......@@ -241,6 +243,35 @@ describe("browser recording", () => {
241243 await stopBrowserRecording("recording-tab");
242244 });
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
244275 it("stops the native stream when MediaRecorder cleanup fails", async () => {
245276 const stopTrack = vi.fn();
246277 getDisplayMedia.mockResolvedValueOnce({
apps/web/src/browser/browserRecording.ts+6-4
......@@ -516,10 +516,12 @@ export async function startBrowserRecording(
516516 activeRecordings.set(tabId, recording);
517517 publishActiveRecordingTabIds();
518518 try {
519 const frameRatePromise = ensureClientSettingsHydrated().then(
520 () => getClientSettings().browserRecordingFrameRate,
521 );
522 const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]);
519 await ensureClientSettingsHydrated().catch((cause: unknown) => {
520 clearActiveRecording(recording);
521 throw cause;
522 });
523 const frameRate = getClientSettings().browserRecordingFrameRate;
524 await waitForBrowserRecordingPaint();
523525 const throwIfStartupCancelled = async (): Promise<void> => {
524526 // Once a grant starts, a stop lets startup finish so the caller receives an artifact.
525527 // 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 @@
11import {
22 DEFAULT_PREVIEW_APPEARANCE,
33 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 DEFAULT_CLIENT_SETTINGS,
45 EnvironmentId,
56 ThreadId,
67} from "@t3tools/contracts";
......@@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({
2122}));
2223
2324import { 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. */
2629const DEFAULT_TAB_STATE = {
2730 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
2831 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
......@@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId";
3134
3235describe("desktopTabLifetime", () => {
3336 beforeEach(() => {
37 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
3438 closeTab.mockClear();
3539 createTab.mockClear();
3640 stopBrowserRecording.mockClear();
......@@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => {
4044 afterEach(() => {
4145 vi.useRealTimers();
4246 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);
4376 });
4477
4578 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(
3838 readonly message: string;
3939}> {}
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
4149export type OpenPreviewMutation<E = unknown> = (input: {
4250 readonly environmentId: EnvironmentId;
4351 readonly input: PreviewOpenInput;
......@@ -47,8 +55,13 @@ export async function openUrlInPreview<E>(input: {
4755 readonly threadRef: ScopedThreadRef;
4856 readonly url: string;
4957 readonly openPreview: OpenPreviewMutation<E>;
50}): Promise<AtomCommandResult<void, E>> {
51 const defaults = await resolveBrowserDefaults();
58}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
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 }
5265 const result = await input.openPreview({
5366 environmentId: input.threadRef.environmentId,
5467 input: {
......@@ -82,7 +95,12 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
8295 readonly input: { readonly resource: AssetResource };
8396 }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;
8497 readonly openPreview: OpenPreviewMutation<PreviewError>;
85}): Promise<AtomCommandResult<void, AssetError | PreviewError | BrowserPreviewUnavailableError>> {
98}): Promise<
99 AtomCommandResult<
100 void,
101 AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
102 >
103> {
86104 if (!isPreviewSupportedInRuntime()) {
87105 return AsyncResult.failure(
88106 Cause.fail(
apps/web/src/browser/useOpenLink.ts+9-4
......@@ -1,5 +1,8 @@
11import 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";
36import { useCallback } from "react";
47
58import { recordVisitForThread } from "~/browserHistoryStore";
......@@ -12,7 +15,7 @@ import {
1215 resolveBrowserLinkTargetPreference,
1316 resolveLinkTarget,
1417} from "./browserLinkTarget";
15import { openUrlInPreview } from "./openFileInPreview";
18import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview";
1619
1720const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
1821
......@@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
2427 *
2528 * An in-app open that fails falls back to the system browser rather than
2629 * 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 that
28 * fallback fails too, the same way `shell.openExternal` does.
30 * where it should go first. Failed settings reads reject without opening a
31 * browser. The promise also rejects if the system-browser fallback fails.
2932 */
3033export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
3134 url: string,
......@@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
5255 recordVisitForThread(targetThreadRef, url);
5356 return;
5457 }
58 const failure = squashAtomCommandFailure(result);
59 if (failure instanceof BrowserSettingsReadError) throw failure;
5560 console.error(result.cause);
5661 }
5762 const api = readLocalApi();
apps/web/src/clientPersistenceStorage.test.ts+31-8
......@@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => {
5252 expect(readBrowserClientSettings()).toEqual(settings);
5353 });
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 () => {
5674 const testWindow = getTestWindow();
57 testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json");
58 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
75 const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const };
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 });
5982 const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
6083
61 expect(readBrowserClientSettings()).toBeNull();
62 expect(consoleError).toHaveBeenCalledWith(
63 "Could not read persisted client settings.",
84 expect(() => readBrowserClientSettings()).toThrow(
6485 expect.objectContaining({
6586 _tag: "LocalStorageOperationError",
66 operation: "decode",
87 operation: "read",
6788 storageKey: "t3code:client-settings:v1",
68 cause: expect.anything(),
89 cause: failure,
6990 }),
7091 );
92 expect(readBrowserClientSettings()).toEqual(settings);
93 expect(write).not.toHaveBeenCalled();
7194 });
7295
7396 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 {
1313 return null;
1414 }
1515
16 try {
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 }
16 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
2217}
2318
2419export function writeBrowserClientSettings(settings: ClientSettings): void {
apps/web/src/components/ChatMarkdown.tsx+15-2
......@@ -176,6 +176,7 @@ import {
176176 openFileInPreview,
177177 openUrlInPreview,
178178 BrowserPreviewUnavailableError,
179 BrowserSettingsReadError,
179180} from "../browser/openFileInPreview";
180181import { resolveLinkTarget } from "../browser/browserLinkTarget";
181182import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
......@@ -2357,6 +2358,18 @@ function useChatMarkdownState({
23572358 }
23582359 return openUrlInPreview({ threadRef, url, openPreview }).then((result) => {
23592360 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 }
23602373 return result;
23612374 });
23622375 },
......@@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
27892802 }
27902803 event.preventDefault();
27912804 event.stopPropagation();
2792 // The click was taken from the shell, so an in-app open that fails
2793 // hands the link to the system browser instead of dropping it.
2805 // Keep the link here if saved settings could not be read.
27942806 void openExternalLinkInPreview(href).then((result) => {
27952807 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
27962808 reportMarkdownActionFailure(
27972809 { operation: "open-link-in-preview", target: href },
27982810 result.cause,
27992811 );
2812 if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return;
28002813 void readLocalApi()?.shell.openExternal(href);
28012814 });
28022815 }}
apps/web/src/components/ChatView.tsx+13
......@@ -162,6 +162,7 @@ import {
162162 useThreadPreviewState,
163163} from "../previewStateStore";
164164import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
165import { BrowserSettingsReadError } from "../browser/openFileInPreview";
165166import { addBrowserSurface } from "./preview/addBrowserSurface";
166167import { closePreviewSession } from "./preview/closePreviewSession";
167168import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
......@@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) {
37183719 threadRef: activeThreadRef,
37193720 openPreview,
37203721 ...(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 }
37213734 });
37223735 },
37233736 [activeThreadRef, openPreview],
apps/web/src/components/ThreadTerminalDrawer.test.ts+88-1
......@@ -1,11 +1,98 @@
1import { describe, expect, it } from "vite-plus/test";
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
33import {
44 shouldClearTerminalSelectionAction,
55 shouldHandleTerminalExit,
6 terminalContextMenuItems,
67 terminalSelectionLineRange,
8 terminalSelectionMenuItems,
9 terminalThemeFromApp,
710} 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
996describe("terminal selection actions", () => {
1097 it("clears a pending or currently owned menu when the selection disappears", () => {
1198 expect(
apps/web/src/components/ThreadTerminalDrawer.tsx+60-17
......@@ -20,6 +20,7 @@ import {
2020} from "lucide-react";
2121import {
2222 type ContextMenuItem,
23 type ProviderInstanceId,
2324 type ResolvedKeybindingsConfig,
2425 type ScopedThreadRef,
2526 type ThreadId,
......@@ -41,6 +42,7 @@ import {
4142import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
4243import { Button } from "~/components/ui/button";
4344import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
45import { stackedThreadToast, toastManager } from "~/components/ui/toast";
4446import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
4547import { cn } from "~/lib/utils";
4648import { type TerminalContextSelection } from "~/lib/terminalContext";
......@@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s
172174}
173175
174176export 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)";
178177 const drawerSurface =
179178 mountElement?.closest(".thread-terminal-drawer") ??
180179 document.querySelector(".thread-terminal-drawer") ??
181180 document.body;
182181 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)";
183192 const bodyStyles = getComputedStyle(document.body);
184 const themeStyles = getComputedStyle(document.documentElement);
193 const rootThemeStyles = getComputedStyle(document.documentElement);
185194 const background = normalizeComputedColor(
186195 drawerStyles.backgroundColor,
187196 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),
......@@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
190199 drawerStyles.color,
191200 normalizeComputedColor(bodyStyles.color, fallbackForeground),
192201 );
193 const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background);
194 const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground);
202 const terminalBackground = readThemeColor(
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 );
195212 const terminalCursor = readThemeColor(
196213 themeStyles,
197214 "--terminal-cursor",
......@@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: {
232249
233250export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";
234251
235/** Post-selection popup: just the two selection actions, always enabled. */
236export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] {
252/** Post-selection popup: available selection actions, always enabled. */
253export function terminalSelectionMenuItems(options?: {
254 canAddToChat?: boolean;
255}): ContextMenuItem<"add-to-chat" | "copy">[] {
237256 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">[])),
239260 { id: "copy", label: "Copy" },
240261 ];
241262}
......@@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c
248269 */
249270export function terminalContextMenuItems(options: {
250271 hasSelection: boolean;
272 canAddToChat?: boolean;
251273}): ContextMenuItem<TerminalContextMenuAction>[] {
274 const { hasSelection, canAddToChat = true } = options;
252275 return [
253 ...terminalSelectionMenuItems().map((item) => ({
276 ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({
254277 ...item,
255 disabled: !options.hasSelection,
278 disabled: !hasSelection,
256279 })),
257280 { id: "paste", label: "Paste" },
258281 ];
......@@ -292,8 +315,9 @@ interface TerminalViewportProps {
292315 cwd: string;
293316 worktreePath?: string | null;
294317 runtimeEnv?: Record<string, string>;
318 providerInstanceId?: ProviderInstanceId;
295319 onSessionExited: () => void;
296 onAddTerminalContext: (selection: TerminalContextSelection) => void;
320 onAddTerminalContext?: (selection: TerminalContextSelection) => void;
297321 focusRequestId: number;
298322 autoFocus: boolean;
299323 visible: boolean;
......@@ -317,6 +341,7 @@ export function TerminalViewport({
317341 cwd,
318342 worktreePath,
319343 runtimeEnv,
344 providerInstanceId,
320345 onSessionExited,
321346 onAddTerminalContext,
322347 focusRequestId,
......@@ -357,8 +382,9 @@ export function TerminalViewport({
357382 onSessionExited();
358383 });
359384 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
360 onAddTerminalContext(selection);
385 onAddTerminalContext?.(selection);
361386 });
387 const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined);
362388 const readTerminalLabel = useEffectEvent(() => terminalLabel);
363389 const terminalFontFamily = useClientSettings((settings) =>
364390 resolveTerminalFontPreference({
......@@ -383,6 +409,7 @@ export function TerminalViewport({
383409 cwd,
384410 ...(worktreePath !== undefined ? { worktreePath } : {}),
385411 ...(runtimeEnv ? { env: runtimeEnv } : {}),
412 ...(providerInstanceId ? { providerInstanceId } : {}),
386413 },
387414 });
388415 const writeTerminal = useEffectEvent((data: string) =>
......@@ -635,7 +662,10 @@ export function TerminalViewport({
635662 let clicked: TerminalContextMenuAction | null;
636663 try {
637664 clicked = await localApi.contextMenu.show(
638 terminalContextMenuItems({ hasSelection: selectionAction !== null }),
665 terminalContextMenuItems({
666 hasSelection: selectionAction !== null,
667 canAddToChat: canAddSelectionToChat(),
668 }),
639669 { x: event.clientX, y: event.clientY },
640670 );
641671 } catch (error) {
......@@ -648,7 +678,9 @@ export function TerminalViewport({
648678 }
649679 switch (clicked) {
650680 case "add-to-chat":
651 if (selectionAction) addSelectionToChat(selectionAction.selection);
681 if (selectionAction && canAddSelectionToChat()) {
682 addSelectionToChat(selectionAction.selection);
683 }
652684 return;
653685 case "copy":
654686 if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);
......@@ -675,7 +707,10 @@ export function TerminalViewport({
675707 const requestId = ++selectionActionRequestIdRef.current;
676708 openSelectionMenuRequestIdRef.current = requestId;
677709 const clicked = await localApi.contextMenu
678 .show(terminalSelectionMenuItems(), nextAction.position)
710 .show(
711 terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }),
712 nextAction.position,
713 )
679714 .finally(() => {
680715 if (openSelectionMenuRequestIdRef.current === requestId) {
681716 openSelectionMenuRequestIdRef.current = null;
......@@ -686,7 +721,7 @@ export function TerminalViewport({
686721 }
687722 switch (clicked) {
688723 case "add-to-chat":
689 addSelectionToChat(nextAction.selection);
724 if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection);
690725 return;
691726 case "copy":
692727 await copySelection(nextAction.clipboardText, requestId);
......@@ -768,6 +803,14 @@ export function TerminalViewport({
768803 threadRef,
769804 openPreview,
770805 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 );
771814 });
772815 return;
773816 }
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 {
1111import type { EnvironmentId } from "@t3tools/contracts";
1212import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
1313import * as Option from "effect/Option";
14import { type ReactNode, useCallback, useEffect, useState } from "react";
14import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react";
1515
1616import { environmentCatalog } from "~/connection/catalog";
1717import { cn } from "~/lib/utils";
......@@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton";
2525import { toastManager } from "../ui/toast";
2626import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
2727
28const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;
29
2830export interface SavedCloudEnvironmentConnection {
2931 readonly environmentId: EnvironmentId;
3032 readonly connection: EnvironmentConnectionPresentation;
......@@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({
5557 primaryEnvironmentId,
5658 savedEnvironments,
5759 showSavedEnvironments = false,
60 refreshWhileEmpty = false,
5861 empty = null,
5962}: {
6063 readonly primaryEnvironmentId: EnvironmentId | null;
6164 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;
6265 readonly showSavedEnvironments?: boolean;
66 readonly refreshWhileEmpty?: boolean;
6367 readonly empty?: ReactNode;
6468}) {
6569 const environmentsState = useRelayEnvironmentDiscovery();
......@@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({
6973 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
7074 reportFailure: false,
7175 });
76 const refreshDiscoveryWhenIdle = useEffectEvent(async () => {
77 if (environmentsState.refreshing || environmentsState.offline) return;
78 await refreshRelayEnvironments();
79 });
7280 const connectRelayEnvironment = useCallback(
7381 (environment: RelayClientEnvironmentRecord) =>
7482 registerEnvironment(
......@@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({
8997 );
9098
9199 useEffect(() => {
92 void refreshRelayEnvironments();
93 }, [refreshRelayEnvironments]);
100 if (!refreshWhileEmpty || document.visibilityState === "visible") {
101 void refreshRelayEnvironments();
102 }
103 }, [refreshRelayEnvironments, refreshWhileEmpty]);
94104
95105 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
96106 setConnectingEnvironmentId(environment.environmentId);
......@@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({
132142 environment.environmentId !== primaryEnvironmentId &&
133143 (showSavedEnvironments || !savedById.has(environment.environmentId)),
134144 );
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
136189 const standalone = showSavedEnvironments || savedEnvironments.length === 0;
137190
138191 if (
192 !refreshWhileEmpty &&
139193 standalone &&
140194 visibleEnvironments.length === 0 &&
141195 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 {
4141 acquireBrowserSurfaceActivity,
4242 useBrowserSurfaceStore,
4343} from "~/browser/browserSurfaceStore";
44import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
44import {
45 browserDefaultOpenProfileId,
46 browserDefaultOpenViewport,
47 resolveBrowserDefaults,
48} from "~/browser/browserDefaults";
4549import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
4650import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
4751import { isElectron } from "~/env";
......@@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
412416 const reusedExistingTab = activeTabId !== null;
413417 tabId = activeTabId;
414418 if (!activeTabId) {
419 const defaults = await resolveBrowserDefaults();
415420 const result = await open({
416421 environmentId,
417422 input: {
......@@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
419424 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
420425 // An agent that didn't state a size gets the user's
421426 // configured default, same as a hand-opened tab.
422 viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()),
427 viewport: browserDefaultOpenViewport(defaults),
428 profileId: browserDefaultOpenProfileId(defaults),
423429 },
424430 });
425431 if (result._tag === "Failure") {
apps/web/src/components/preview/PreviewView.tsx+15-1
......@@ -1,7 +1,10 @@
11"use client";
22
33import { 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";
58import {
69 DEFAULT_BROWSER_PROFILE_ID,
710 FILL_PREVIEW_VIEWPORT,
......@@ -46,6 +49,7 @@ import {
4649} from "~/browser/browserViewportActions";
4750import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
4851import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
52import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
4953import { PreviewUnreachable } from "./PreviewUnreachable";
5054import { revealInFileExplorerLabel } from "./fileExplorerLabel";
5155import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
......@@ -186,6 +190,16 @@ export function PreviewView({
186190 return true;
187191 }
188192 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 }
189203 return result._tag === "Success";
190204 },
191205 [open, runtimeTabId, threadRef],
apps/web/src/components/preview/addBrowserSurface.test.ts+3
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_BROWSER_PROFILE_ID,
3 DEFAULT_CLIENT_SETTINGS,
34 FILL_PREVIEW_VIEWPORT,
45 type PreviewOpenInput,
56 type PreviewSessionSnapshot,
......@@ -14,6 +15,7 @@ import {
1415 resetPreviewStateForTests,
1516} from "~/previewStateStore";
1617import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";
18import { __setClientSettingsForTests } from "~/hooks/useSettings";
1719
1820import { addBrowserSurface } from "./addBrowserSurface";
1921
......@@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({
3234});
3335
3436beforeEach(() => {
37 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
3538 resetPreviewStateForTests();
3639 useRightPanelStore.setState({ byThreadKey: {} });
3740});
apps/web/src/components/preview/addBrowserSurface.ts+2-2
......@@ -4,7 +4,7 @@ import {
44} from "@t3tools/client-runtime/state/runtime";
55import type { ScopedThreadRef } from "@t3tools/contracts";
66
7import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
7import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview";
88import { useRightPanelStore } from "~/rightPanelStore";
99
1010import { openPreviewSession } from "./openPreviewSession";
......@@ -15,7 +15,7 @@ export async function addBrowserSurface<E>(input: {
1515 readonly openPreview: OpenPreviewMutation<E>;
1616 /** Omit to use the configured default profile. */
1717 readonly profileId?: string | undefined;
18}): Promise<AtomCommandResult<void, E>> {
18}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
1919 const result = await openPreviewSession({
2020 openPreview: input.openPreview,
2121 threadRef: input.threadRef,
apps/web/src/components/preview/openDiscoveredPort.ts+2-2
......@@ -5,7 +5,7 @@ import {
55} from "@t3tools/client-runtime/state/runtime";
66
77import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
8import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
8import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview";
99import { recordVisitForThread } from "~/browserHistoryStore";
1010import { useRightPanelStore } from "~/rightPanelStore";
1111import { openPreviewSession } from "./openPreviewSession";
......@@ -14,7 +14,7 @@ export async function openDiscoveredPort<E>(input: {
1414 readonly threadRef: ScopedThreadRef;
1515 readonly port: DiscoveredLocalServer;
1616 readonly openPreview: OpenPreviewMutation<E>;
17}): Promise<AtomCommandResult<void, E>> {
17}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
1818 const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url);
1919 const result = await openPreviewSession({
2020 openPreview: input.openPreview,
apps/web/src/components/preview/openPreviewSession.test.ts+53-2
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_BROWSER_PROFILE_ID,
3 DEFAULT_CLIENT_SETTINGS,
34 FILL_PREVIEW_VIEWPORT,
45 type PreviewOpenInput,
56 type PreviewSessionSnapshot,
......@@ -7,8 +8,11 @@ import {
78} from "@t3tools/contracts";
89import * as Cause from "effect/Cause";
910import { 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";
1216import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";
1317
1418import { openPreviewSession } from "./openPreviewSession";
......@@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = {
3135 updatedAt: "2026-06-11T23:00:00.000Z",
3236};
3337
34beforeEach(resetPreviewStateForTests);
38beforeEach(() => {
39 resetPreviewStateForTests();
40 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
41});
42
43afterEach(() => {
44 vi.restoreAllMocks();
45});
3546
3647describe("openPreviewSession", () => {
3748 it("creates an idle tab without recording a recently visited URL", async () => {
......@@ -88,4 +99,44 @@ describe("openPreviewSession", () => {
8899 expect(readThreadPreviewState(threadRef).snapshot).toBeNull();
89100 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
90101 });
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 );
91142});
apps/web/src/components/preview/openPreviewSession.ts+10-2
......@@ -6,12 +6,15 @@ import type {
66 ScopedThreadRef,
77} from "@t3tools/contracts";
88import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
9import * as Cause from "effect/Cause";
10import { AsyncResult } from "effect/unstable/reactivity";
911
1012import {
1113 browserDefaultOpenProfileId,
1214 browserDefaultOpenViewport,
1315 resolveBrowserDefaults,
1416} from "~/browser/browserDefaults";
17import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
1518import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1619
1720interface OpenPreviewSessionInput<E> {
......@@ -29,10 +32,15 @@ interface OpenPreviewSessionInput<E> {
2932
3033export async function openPreviewSession<E>(
3134 input: OpenPreviewSessionInput<E>,
32): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> {
35): Promise<AtomCommandResult<PreviewSessionSnapshot, E | BrowserSettingsReadError>> {
3336 // Resolved once: a tab opened before client settings hydrate would otherwise
3437 // 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 }
3644 const result = await input.openPreview({
3745 environmentId: input.threadRef.environmentId,
3846 input: {
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+27
......@@ -68,6 +68,33 @@ afterEach(() => {
6868});
6969
7070describe("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
7198 it("opens in the system browser while that is the configured target", async () => {
7299 linkTargetMocks.preference.mockReturnValue("system");
73100 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;
2626 * settings page. Prefers `provider.message` for server-supplied detail and
2727 * falls back to generic phrasing when the server has not yet reported any
2828 * 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.
3031 */
3132export function getProviderSummary(provider: ServerProvider | undefined) {
3233 if (!provider) {
......@@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
3536 detail: "Waiting for the server to report installation and authentication details.",
3637 };
3738 }
38 if (!provider.enabled) {
39 if (!provider.enabled || provider.status === "disabled") {
3940 return {
4041 headline: "Disabled",
4142 detail:
......@@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
4849 detail: provider.message ?? "CLI not detected on PATH.",
4950 };
5051 }
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 }
5852 if (provider.auth.status === "unauthenticated") {
5953 return {
6054 headline: "Not authenticated",
......@@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
7468 detail: provider.message ?? "The provider failed its startup checks.",
7569 };
7670 }
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 }
7778 return {
7879 headline: "Available",
79 detail: provider.message ?? "Installed and ready, but authentication could not be verified.",
80 detail: provider.message ?? null,
8081 };
8182}
8283
apps/web/src/environments/primary/auth.ts+2
......@@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
353353
354354 resolvedAuthenticatedGateState = null;
355355 await exchangeBootstrapCredential(trimmedCredential);
356 await waitForAuthenticatedSessionAfterBootstrap();
357 resolvedAuthenticatedGateState = { status: "authenticated" };
356358 bootstrapPromise = null;
357359 stripPairingTokenFromUrl();
358360}
apps/web/src/hooks/useLocalStorage.test.ts+21-2
......@@ -55,9 +55,28 @@ describe("local storage errors", () => {
5555 }
5656 });
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) => {
5978 const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage(
60 createStorage({ getItem: () => "not-json" }),
79 createStorage({ getItem: () => value }),
6180 );
6281
6382 try {
apps/web/src/hooks/useLocalStorage.ts+20-20
......@@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass<LocalSto
1515 }
1616}
1717
18const isomorphicLocalStorage: Storage =
19 typeof window !== "undefined"
20 ? window.localStorage
21 : (function () {
22 const store = new Map<string, string>();
23 return {
24 clear: () => store.clear(),
25 getItem: (_) => store.get(_) ?? null,
26 key: (_) => Record.keys(store).at(_) ?? null,
27 get length() {
28 return store.size;
29 },
30 removeItem: (_) => store.delete(_),
31 setItem: (_, value) => store.set(_, value),
32 };
33 })();
18const fallbackStorage: Storage = (() => {
19 const store = new Map<string, string>();
20 return {
21 clear: () => store.clear(),
22 getItem: (_) => store.get(_) ?? null,
23 key: (_) => Record.keys(store).at(_) ?? null,
24 get length() {
25 return store.size;
26 },
27 removeItem: (_) => store.delete(_),
28 setItem: (_, value) => store.set(_, value),
29 };
30})();
31
32const getStorage = (): Storage =>
33 typeof window !== "undefined" ? window.localStorage : fallbackStorage;
3434
3535const read = (key: string) => {
3636 try {
37 return isomorphicLocalStorage.getItem(key);
37 return getStorage().getItem(key);
3838 } catch (cause) {
3939 throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause });
4040 }
......@@ -58,13 +58,13 @@ const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
5858
5959export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
6060 const item = read(key);
61 return item ? decode(key, schema, item) : null;
61 return item === null ? null : decode(key, schema, item);
6262};
6363
6464export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
6565 const valueToSet = encode(key, schema, value);
6666 try {
67 isomorphicLocalStorage.setItem(key, valueToSet);
67 getStorage().setItem(key, valueToSet);
6868 } catch (cause) {
6969 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });
7070 }
......@@ -72,7 +72,7 @@ export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.
7272
7373export const removeLocalStorageItem = (key: string) => {
7474 try {
75 isomorphicLocalStorage.removeItem(key);
75 getStorage().removeItem(key);
7676 } catch (cause) {
7777 throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause });
7878 }
apps/web/src/hooks/useSettings.test.ts+178-2
......@@ -3,12 +3,22 @@ import {
33 ProviderDriverKind,
44 ProviderInstanceId,
55} from "@t3tools/contracts";
6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
7import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings";
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
918import {
1019 __resetClientSettingsPersistenceForTests,
1120 __setClientSettingsForTests,
21 ensureClientSettingsHydrated,
1222 getClientSettings,
1323 mergeEnvironmentSettings,
1424 persistClientSettingsPatch,
......@@ -17,9 +27,138 @@ import {
1727} from "./useSettings";
1828
1929beforeEach(() => {
30 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);
31 persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined);
2032 __resetClientSettingsPersistenceForTests();
2133});
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
23162describe("persistClientSettingsUpdate", () => {
24163 it("publishes the update only after persistence succeeds", async () => {
25164 let finishPersistence!: () => void;
......@@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => {
245384 expect(settings.sidebarAutoSettleOnMerge).toBe(false);
246385 });
247386});
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;
5454
5555const clientSettingsListeners = new Set<() => void>();
5656const clientSettingsHydrationListeners = new Set<() => void>();
57type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";
5758let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
58let clientSettingsHydrated = false;
59let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending";
5960let clientSettingsHydrationPromise: Promise<void> | null = null;
6061let clientSettingsHydrationGeneration = 0;
6162let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();
63let deferredClientSettingsPatchCount = 0;
6264
6365function emitClientSettingsChange() {
6466 for (const listener of clientSettingsListeners) {
......@@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void {
8183 emitClientSettingsChange();
8284}
8385
84function setClientSettingsHydrated(nextHydrated: boolean): void {
85 if (clientSettingsHydrated === nextHydrated) {
86function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void {
87 if (clientSettingsHydrationStatus === nextStatus) {
8688 return;
8789 }
88 clientSettingsHydrated = nextHydrated;
90 clientSettingsHydrationStatus = nextStatus;
8991 emitClientSettingsHydrationChange();
9092}
9193
9294function subscribeClientSettings(listener: () => void): () => void {
9395 clientSettingsListeners.add(listener);
94 void hydrateClientSettings();
96 void hydrateClientSettings().catch(() => undefined);
9597 return () => {
9698 clientSettingsListeners.delete(listener);
9799 };
98100}
99101
100102function getClientSettingsHydratedSnapshot(): boolean {
101 return clientSettingsHydrated;
103 return clientSettingsHydrationStatus === "ready";
104}
105
106function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus {
107 return clientSettingsHydrationStatus;
102108}
103109
104110function subscribeClientSettingsHydration(listener: () => void): () => void {
105111 clientSettingsHydrationListeners.add(listener);
106 void hydrateClientSettings();
112 void hydrateClientSettings().catch(() => undefined);
107113 return () => {
108114 clientSettingsHydrationListeners.delete(listener);
109115 };
110116}
111117
112118async function hydrateClientSettings(): Promise<void> {
113 if (clientSettingsHydrated) {
119 if (clientSettingsHydrationStatus === "ready") {
114120 return;
115121 }
116122 if (clientSettingsHydrationPromise) {
......@@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise<void> {
118124 }
119125
120126 const hydrationGeneration = clientSettingsHydrationGeneration;
127 setClientSettingsHydrationStatus(
128 clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying"
129 ? "retrying"
130 : "pending",
131 );
121132 const nextHydration = (async () => {
122133 try {
123134 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
......@@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise<void> {
127138 if (persistedSettings) {
128139 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
129140 }
141 setClientSettingsHydrationStatus("ready");
130142 } catch (error) {
143 if (hydrationGeneration === clientSettingsHydrationGeneration) {
144 setClientSettingsHydrationStatus("failed");
145 }
131146 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {
132147 operation: "hydrate",
133148 ...safeErrorLogAttributes(error),
134149 });
135 } finally {
136 if (hydrationGeneration === clientSettingsHydrationGeneration) {
137 setClientSettingsHydrated(true);
138 }
150 throw error;
139151 }
140152 })();
141153
......@@ -165,15 +177,32 @@ export function persistClientSettingsPatch(
165177 patch: ClientSettingsPatch,
166178 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
167179): void {
168 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
169 void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch(
170 (error) => {
171 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
172 operation: "persist",
173 ...safeErrorLogAttributes(error),
174 });
175 },
176 );
180 // Patches queued before hydration must publish before newer optimistic patches.
181 const deferPatch =
182 clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;
183 if (deferPatch) {
184 deferredClientSettingsPatchCount += 1;
185 } else {
186 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
187 }
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 });
177206}
178207
179208/**
......@@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate(
187216 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
188217): Promise<ClientSettings> {
189218 return enqueueClientSettingsPersistence(async () => {
219 if (clientSettingsHydrationStatus !== "ready") {
220 await hydrateClientSettings();
221 }
190222 for (;;) {
191223 const current = getClientSettingsSnapshot();
192224 const next = update(current);
......@@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings {
234266}
235267
236268/**
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.
238272 *
239273 * The pre-hydration snapshot is just the schema defaults, so imperative paths
240274 * that open a preview must await this or they bake the built-in viewport, zoom
......@@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean {
252286 );
253287}
254288
289export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus {
290 return useSyncExternalStore(
291 subscribeClientSettingsHydration,
292 getClientSettingsHydrationStatusSnapshot,
293 () => "pending",
294 );
295}
296
255297function useClientSettingsValue(): ClientSettings {
256298 return useSyncExternalStore(
257299 subscribeClientSettings,
......@@ -524,9 +566,10 @@ export function useUpdateClientSettings() {
524566export function __resetClientSettingsPersistenceForTests(): void {
525567 clientSettingsHydrationGeneration += 1;
526568 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
527 clientSettingsHydrated = false;
569 clientSettingsHydrationStatus = "pending";
528570 clientSettingsHydrationPromise = null;
529571 clientSettingsPersistenceQueue = Promise.resolve();
572 deferredClientSettingsPatchCount = 0;
530573 clientSettingsListeners.clear();
531574 clientSettingsHydrationListeners.clear();
532575}
......@@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void {
534577export function __setClientSettingsForTests(settings: ClientSettings): void {
535578 clientSettingsHydrationGeneration += 1;
536579 clientSettingsSnapshot = settings;
537 clientSettingsHydrated = true;
580 clientSettingsHydrationStatus = "ready";
538581 clientSettingsHydrationPromise = null;
539582}
apps/web/src/hooks/useTheme.test.ts+197
......@@ -204,3 +204,200 @@ describe("theme failure handling", () => {
204204 }
205205 });
206206});
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 } {
9898function themeHalvesSignature(halves: ThemeHalves | null): string {
9999 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
100100}
101
102function isOnboardingThemeActive(): boolean {
103 return (
104 typeof document !== "undefined" &&
105 document.documentElement.dataset?.onboardingSurface !== undefined
106 );
107}
108
101109const THEME_COLOR_META_NAME = "theme-color";
102110const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
103111
......@@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement {
292300
293301export function syncBrowserChromeTheme() {
294302 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
303 const onboardingActive = isOnboardingThemeActive();
295304 const rootStyles = getComputedStyle(document.documentElement);
296 const themeChromeColor = document.documentElement.dataset.themeId
297 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
298 : null;
305 const themeChromeColor =
306 !onboardingActive && document.documentElement.dataset.themeId
307 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
308 : null;
299309 const surfaceColor = normalizeThemeColor(
300310 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
301311 );
302312 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
303 const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;
313 const backgroundColor = onboardingActive
314 ? "#000"
315 : (themeChromeColor ?? surfaceColor ?? fallbackColor);
304316 if (!backgroundColor) return;
305317
306318 document.documentElement.style.backgroundColor = backgroundColor;
......@@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() {
321333
322334function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {
323335 if (typeof document === "undefined" || typeof window === "undefined") return;
336 const onboardingActive = isOnboardingThemeActive();
324337 // 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 }
326345 const appearanceMode = readAppearanceModePreference(theme);
327346 const followSystem = appearanceMode === "system";
328347 const systemDark = followSystem ? getSystemDark() : false;
......@@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
334353 lastAppliedTheme.appearanceMode === appearanceMode &&
335354 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)
336355 ) {
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 }
338363 return;
339364 }
340365
......@@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
348373 appearanceMode,
349374 themeHalves,
350375 );
351 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
352 const isDark = resolvedAppearance === "dark";
353 document.documentElement.classList.toggle("dark", isDark);
376 if (onboardingActive) {
377 document.documentElement.classList.add("dark");
378 } else {
379 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
380 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
381 }
354382 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
355383 syncBrowserChromeTheme();
356 syncDesktopTheme(theme, followSystem, appearanceMode);
384 if (onboardingActive) {
385 syncDesktopTheme("dark", false, "dark");
386 } else {
387 syncDesktopTheme(theme, followSystem, appearanceMode);
388 }
357389 if (suppressTransitions) {
358390 // Force a reflow so the no-transitions class takes effect before removal
359391 void document.documentElement.offsetHeight;
......@@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
363395 }
364396}
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
366420export async function syncDesktopThemePreference(
367421 bridge: DesktopThemeBridge,
368422 theme: Theme,
......@@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot {
424478 const systemDark = followSystem ? getSystemDark() : false;
425479 const themeHalves = readStoredThemeHalves();
426480
427 const resolvedTheme = resolveThemeAppearance(
428 theme,
429 systemDark,
430 followSystem,
431 appearanceMode,
432 themeHalves,
433 );
481 const resolvedTheme = isOnboardingThemeActive()
482 ? "dark"
483 : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves);
434484 if (
435485 lastSnapshot &&
436486 lastSnapshot.theme === theme &&
apps/web/src/index.css+36-4
......@@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) {
11841184 --terminal-selection-background: var(--app-theme-terminal-selection-background);
11851185}
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
11871220/* Theme-token dependency probes are restored synchronously, before paint. Keep
11881221 transitions from observing the temporary sentinel color in between. */
11891222html[data-theme-token-probe],
......@@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] {
13851418 }
13861419}
13871420
1388/* Contrast stays in ordinary custom properties so both Tailwind utilities and
1389 global/imperative chrome styles resolve the same adjusted role. Redeclare on
1390 the sidebar because it owns a local semantic palette. */
1421/* Recompute contrast wherever a subtree owns its own semantic color palette. */
13911422:root,
1392[data-app-sidebar] {
1423[data-app-sidebar],
1424[data-onboarding-surface] {
13931425 --contrast-toolbar-foreground: color-mix(
13941426 in oklab,
13951427 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 @@
99// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
1111import { Route as rootRouteImport } from './routes/__root'
12import { Route as WelcomeRouteImport } from './routes/welcome'
1213import { Route as UsageRouteImport } from './routes/usage'
1314import { Route as SettingsRouteImport } from './routes/settings'
1415import { Route as PairRouteImport } from './routes/pair'
......@@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques
3031import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
3132import { 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)
3339const UsageRoute = UsageRouteImport.update({
3440 id: '/usage',
3541 path: '/usage',
......@@ -137,6 +143,7 @@ export interface FileRoutesByFullPath {
137143 '/pair': typeof PairRoute
138144 '/settings': typeof SettingsRouteWithChildren
139145 '/usage': typeof UsageRoute
146 '/welcome': typeof WelcomeRoute
140147 '/pull-requests': typeof ChatPullRequestsRoute
141148 '/connect/callback': typeof ConnectCallbackRoute
142149 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
......@@ -157,6 +164,7 @@ export interface FileRoutesByTo {
157164 '/pair': typeof PairRoute
158165 '/settings': typeof SettingsRouteWithChildren
159166 '/usage': typeof UsageRoute
167 '/welcome': typeof WelcomeRoute
160168 '/pull-requests': typeof ChatPullRequestsRoute
161169 '/connect/callback': typeof ConnectCallbackRoute
162170 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
......@@ -180,6 +188,7 @@ export interface FileRoutesById {
180188 '/pair': typeof PairRoute
181189 '/settings': typeof SettingsRouteWithChildren
182190 '/usage': typeof UsageRoute
191 '/welcome': typeof WelcomeRoute
183192 '/_chat/pull-requests': typeof ChatPullRequestsRoute
184193 '/connect_/callback': typeof ConnectCallbackRoute
185194 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
......@@ -204,6 +213,7 @@ export interface FileRouteTypes {
204213 | '/pair'
205214 | '/settings'
206215 | '/usage'
216 | '/welcome'
207217 | '/pull-requests'
208218 | '/connect/callback'
209219 | '/projects/$projectKey'
......@@ -224,6 +234,7 @@ export interface FileRouteTypes {
224234 | '/pair'
225235 | '/settings'
226236 | '/usage'
237 | '/welcome'
227238 | '/pull-requests'
228239 | '/connect/callback'
229240 | '/projects/$projectKey'
......@@ -246,6 +257,7 @@ export interface FileRouteTypes {
246257 | '/pair'
247258 | '/settings'
248259 | '/usage'
260 | '/welcome'
249261 | '/_chat/pull-requests'
250262 | '/connect_/callback'
251263 | '/projects/$projectKey'
......@@ -269,12 +281,20 @@ export interface RootRouteChildren {
269281 PairRoute: typeof PairRoute
270282 SettingsRoute: typeof SettingsRouteWithChildren
271283 UsageRoute: typeof UsageRoute
284 WelcomeRoute: typeof WelcomeRoute
272285 ConnectCallbackRoute: typeof ConnectCallbackRoute
273286 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
274287}
275288
276289declare module '@tanstack/react-router' {
277290 interface FileRoutesByPath {
291 '/welcome': {
292 id: '/welcome'
293 path: '/welcome'
294 fullPath: '/welcome'
295 preLoaderRoute: typeof WelcomeRouteImport
296 parentRoute: typeof rootRouteImport
297 }
278298 '/usage': {
279299 id: '/usage'
280300 path: '/usage'
......@@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = {
468488 PairRoute: PairRoute,
469489 SettingsRoute: SettingsRouteWithChildren,
470490 UsageRoute: UsageRoute,
491 WelcomeRoute: WelcomeRoute,
471492 ConnectCallbackRoute: ConnectCallbackRoute,
472493 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
473494}
apps/web/src/routes/__root.tsx+58-16
......@@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic";
1616import { AppSidebarLayout } from "../components/AppSidebarLayout";
1717import { CommandPalette } from "../components/CommandPalette";
1818import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
19import { FirstRunGate } from "../components/onboarding/FirstRunGate";
1920import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
2021import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
2122import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
......@@ -97,6 +98,13 @@ function RootRouteView() {
9798 const pathname = useLocation({ select: (location) => location.pathname });
9899 const { authGateState } = Route.useRouteContext();
99100 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
101109 useEffect(() => {
102110 const frame = window.requestAnimationFrame(() => {
......@@ -116,6 +124,19 @@ function RootRouteView() {
116124 );
117125 }
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
119140 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
120141 return (
121142 <>
......@@ -133,6 +154,10 @@ function RootRouteView() {
133154 </CommandPalette>
134155 );
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.
136161 return (
137162 <ToastProvider>
138163 <AnchoredToastProvider>
......@@ -141,21 +166,28 @@ function RootRouteView() {
141166 <EnvironmentThemeSync />
142167 <GlassAppearanceSync />
143168 <FontAppearanceSync />
144 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
145 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
146 <RelayClientInstallDialog />
147 <ConnectOnboardingDialog />
148 <SshPasswordPromptDialog />
149 <ConfirmDialogHost />
150 <SlowRpcRequestToastCoordinator />
151 <HostedStaticEnvironmentBootstrap />
152 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
153 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}
154 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
155 {appShell}
156 {/* Above the router: a theme draft is judged by walking the app, so the
157 editor has to survive navigation away from settings. */}
158 <ThemeEditorHost />
169 <FirstRunGate
170 enabled={primaryEnvironmentAuthenticated}
171 hostedStatic={authGateState.status === "hosted-static"}
172 >
173 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
174 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
175 <RelayClientInstallDialog />
176 <ConnectOnboardingDialog />
177 <SshPasswordPromptDialog />
178 <ConfirmDialogHost />
179 <SlowRpcRequestToastCoordinator />
180 <HostedStaticEnvironmentBootstrap />
181 {primaryEnvironmentAuthenticated ? (
182 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
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>
159191 </AnchoredToastProvider>
160192 </ToastProvider>
161193 );
......@@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() {
381413 return null;
382414}
383415
384function EventRouter() {
416function EventRouter({
417 skipInitialBootstrapNavigation,
418}: {
419 readonly skipInitialBootstrapNavigation: boolean;
420}) {
385421 const navigate = useNavigate();
386422 const pathname = useLocation({ select: (loc) => loc.pathname });
387423 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
......@@ -394,6 +430,7 @@ function EventRouter() {
394430 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
395431 const readPathname = useEffectEvent(() => pathname);
396432 const handledBootstrapThreadIdRef = useRef<string | null>(null);
433 const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation);
397434 const handledConfigEventRef = useRef(serverConfigEvent);
398435 const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() =>
399436 createKeybindingsUpdateToastController({}),
......@@ -425,6 +462,11 @@ function EventRouter() {
425462 if (readPathname() !== "/") {
426463 return;
427464 }
465 if (skipInitialBootstrapNavigationRef.current) {
466 skipInitialBootstrapNavigationRef.current = false;
467 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
468 return;
469 }
428470 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
429471 return;
430472 }
apps/web/src/routes/_chat.index.tsx+6-3
......@@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig";
2121
2222function ChatIndexRouteView() {
2323 const { authGateState } = Route.useRouteContext();
24 const { environments } = useEnvironments();
24 const { environments, isReady } = useEnvironments();
2525
26 if (authGateState.status === "hosted-static" && environments.length === 0) {
27 return <HostedStaticOnboardingState />;
26 if (authGateState.status === "hosted-static") {
27 if (!isReady) return null;
28 if (environments.length === 0) return <HostedStaticOnboardingState />;
2829 }
2930
3031 return <IndexDraftLanding />;
......@@ -79,6 +80,8 @@ function IndexDraftLanding() {
7980 />
8081 ) : null;
8182 }
83 // First-run routing to the welcome wizard happens in FirstRunGate at the
84 // root, before this route ever renders.
8285 return <NoProjectsHero />;
8386}
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 {
33 EnvironmentId,
44 type RelayClientInstallProgressEvent,
55 type ServerConfigStreamEvent,
6 type ServerLifecycleStreamEvent,
67 WS_METHODS,
78} from "@t3tools/contracts";
89import { describe, expect, it } from "@effect/vitest";
910import * as Cause from "effect/Cause";
11import * as Deferred from "effect/Deferred";
1012import * as Effect from "effect/Effect";
1113import * as Exit from "effect/Exit";
1214import * as Fiber from "effect/Fiber";
......@@ -27,7 +29,13 @@ import {
2729import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2830import * as RpcSession from "../rpc/session.ts";
2931import 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
3240const TARGET = new PrimaryConnectionTarget({
3341 environmentId: EnvironmentId.make("environment-1"),
......@@ -221,6 +229,72 @@ describe("environment RPC", () => {
221229 }),
222230 );
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
224298 it.effect("keeps durable subscriptions alive across a transport failure and new session", () =>
225299 Effect.gen(function* () {
226300 const subscriptions: string[] = [];
packages/client-runtime/src/rpc/client.ts+38-11
......@@ -178,15 +178,15 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> {
178178 readonly resubscribe?: Stream.Stream<unknown, never, never>;
179179}
180180
181export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
181function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
182182 tag: TTag,
183183 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>>,
184188 options?: SubscriptionOptions<TTag>,
185): Stream.Stream<
186 EnvironmentRpcStreamValue<TTag>,
187 EnvironmentRpcStreamFailure<TTag>,
188 EnvironmentSupervisor
189> {
189): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>, EnvironmentSupervisor> {
190190 return Stream.unwrap(
191191 Effect.gen(function* () {
192192 const supervisor = yield* EnvironmentSupervisor;
......@@ -216,10 +216,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
216216 EnvironmentRpcStreamValue<TTag>,
217217 EnvironmentRpcStreamFailure<TTag>
218218 >;
219 const subscribeToSession = (): Stream.Stream<
220 EnvironmentRpcStreamValue<TTag>,
221 EnvironmentRpcStreamFailure<TTag>
222 > =>
219 const subscribeToSession = (): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>> =>
223220 Stream.suspend(() =>
224221 Stream.unwrap(
225222 Effect.gen(function* () {
......@@ -229,7 +226,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
229226 method: tag,
230227 input,
231228 });
232 return method(input).pipe(
229 return mapStream(session, method(input)).pipe(
233230 Stream.ensuring(completeObservation),
234231 Stream.catchCause((cause) => {
235232 const hasOnlyExpectedFailures =
......@@ -287,6 +284,36 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
287284 );
288285}
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
290317export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
291318 tag: TTag,
292319 input: EnvironmentRpcInput<TTag>,
packages/client-runtime/src/state/server.test.ts+211-7
......@@ -7,8 +7,8 @@ import {
77} from "@t3tools/contracts";
88import { describe, expect, it } from "@effect/vitest";
99import * as Cause from "effect/Cause";
10import * as Duration from "effect/Duration";
1110import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
1212import * as Effect from "effect/Effect";
1313import * as Exit from "effect/Exit";
1414import * as Fiber from "effect/Fiber";
......@@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts";
3131import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
3232import type { RpcSession } from "../rpc/session.ts";
3333import {
34 applyServerWelcomeEvent,
35 makeEnvironmentServerWelcomeState,
3436 makeEnvironmentServerConfigState,
3537 isLegacyUpdateHandoffLoss,
3638 matchesServerUpdateReadyEvent,
3739 matchesServerUpdateResumeEvent,
3840 nudgeReconnectDuringUpdateRestart,
39 projectServerWelcome,
4041 resolveServerConfigValue,
42 resolveServerWelcomeState,
4143 resolveServerUpdateProgressResult,
4244 serverUpdateStateForProgressEvent,
4345 serverUpdateStateForServerVersion,
......@@ -494,25 +496,227 @@ describe("server state projection", () => {
494496 expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined();
495497 });
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);
498502 const welcome = {
499503 environment: {} as ServerLifecycleWelcomePayload["environment"],
500504 cwd: "/repo",
501505 projectName: "repo",
502506 } 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, {
504513 type: "welcome",
505514 payload: welcome,
506515 });
507 const [afterReady, emitted] = projectServerWelcome(afterWelcome, {
516 const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, {
508517 type: "ready",
509518 payload: {},
510519 });
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);
513 expect(emitted).toEqual([]);
526 expect(afterReady).toBe(afterWelcome);
527 expect(resolveServerWelcomeState(afterReady)).toBe(welcome);
528 expect(afterBufferedOldWelcome).toBe(afterSwitch);
529 expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull();
514530 });
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
516720 it("prefers an active session config over cache until a live event arrives", () => {
517721 const config = (source: string, serverVersion: string) =>
518722 ({
packages/client-runtime/src/state/server.ts+133-19
......@@ -40,8 +40,10 @@ import {
4040 request,
4141 runStream,
4242 subscribe,
43 subscribeDynamicWithSession,
4344 type EnvironmentRpcInput,
4445} from "../rpc/client.ts";
46import type { RpcSession } from "../rpc/session.ts";
4547import { followStreamInEnvironment } from "./runtime.ts";
4648import {
4749 applyServerConfigProjection,
......@@ -476,21 +478,119 @@ export function serverConfigStateChanges(
476478 );
477479}
478480
479export function projectServerWelcome(
480 current: Option.Option<ServerLifecycleWelcomePayload>,
481export function applyServerWelcomeEvent(
482 current: EnvironmentServerWelcomeState,
483 session: RpcSession,
481484 event: {
482485 readonly type: "welcome" | "ready";
483486 readonly payload: unknown;
484487 },
485): readonly [
486 Option.Option<ServerLifecycleWelcomePayload>,
487 ReadonlyArray<ServerLifecycleWelcomePayload>,
488] {
489 if (event.type !== "welcome") {
490 return [current, []];
491 }
492 const welcome = event.payload as ServerLifecycleWelcomePayload;
493 return [Option.some(welcome), [welcome]];
488): EnvironmentServerWelcomeState {
489 return event.type === "welcome" && current.currentSession === session
490 ? {
491 ...current,
492 welcomeSession: session,
493 welcome: event.payload as ServerLifecycleWelcomePayload,
494 }
495 : current;
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 );
494594}
495595
496596export function resolveServerConfigValue(
......@@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms<R, E>(
833933 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
834934 ),
835935 );
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
837958 return {
838959 configValueAtom,
......@@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms<R, E>(
9161037 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),
9171038 }),
9181039 configProjection,
919 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
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 }),
1040 welcome,
9271041 consumeResetCredit: createEnvironmentRpcCommand(runtime, {
9281042 label: "environment-data:server:consume-reset-credit",
9291043 tag: WS_METHODS.providerConsumeResetCredit,
packages/client-runtime/src/state/threadReducer.test.ts+128
......@@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => {
380380 }
381381 });
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
383417 it("appends text for streaming messages", () => {
384418 const threadWithMessage: OrchestrationThread = {
385419 ...baseThread,
......@@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => {
11781212 });
11791213
11801214 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
11811309 it("filters entities to retained turns", () => {
11821310 const threadWithData: OrchestrationThread = {
11831311 ...baseThread,
packages/client-runtime/src/state/threadReducer.ts+42-10
......@@ -12,6 +12,8 @@ import type {
1212 OrchestrationThreadActivity,
1313 TurnId,
1414} from "@t3tools/contracts";
15import { isImportedAgentSessionMessageId } from "@t3tools/contracts";
16import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1517
1618export type ThreadDetailReducerResult =
1719 | { readonly kind: "updated"; readonly thread: OrchestrationThread }
......@@ -551,7 +553,11 @@ export function applyThreadDetailEvent(
551553 );
552554
553555 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 );
555561 const proposedPlans = pipe(
556562 thread.proposedPlans,
557563 Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)),
......@@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage(
744750function retainMessagesAfterRevert(
745751 messages: ReadonlyArray<OrchestrationMessage>,
746752 retainedTurnIds: ReadonlySet<string>,
753 turnCount: number,
747754): OrchestrationMessage[] {
748 // Keep messages that belong to a retained turn, plus system messages and
749 // messages without a turn binding (pre-turn-0 user messages).
750 return Arr.filter(messages, (message) => {
751 if (message.role === "system") {
752 return true;
755 const retainedMessageIds = new Set<string>();
756 for (const message of messages) {
757 if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) {
758 retainedMessageIds.add(message.id);
759 } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
760 retainedMessageIds.add(message.id);
753761 }
754 if (message.turnId === null) {
755 return true;
762 }
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);
756787 }
757 return retainedTurnIds.has(message.turnId);
758 });
788 }
789
790 return Arr.filter(messages, (message) => retainedMessageIds.has(message.id));
759791}
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";
2929export * from "./editor.ts";
3030export * from "./project.ts";
3131export * from "./filesystem.ts";
32export * from "./agentSessions.ts";
3233export * from "./assets.ts";
3334export * from "./review.ts";
3435export * 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", () =>
11351135 }),
11361136);
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
11381153it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => {
11391154 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true);
11401155 assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true);
packages/contracts/src/orchestration.ts+17
......@@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({
800800 branch: Schema.NullOr(TrimmedNonEmptyString),
801801 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
802802 createdAt: IsoDateTime,
803 historyImport: Schema.optional(Schema.Literal(true)),
803804});
804805
805806const ThreadDeleteCommand = Schema.Struct({
......@@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({
11221123 createdAt: IsoDateTime,
11231124});
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
11251140const ThreadProposedPlanUpsertCommand = Schema.Struct({
11261141 type: Schema.Literal("thread.proposed-plan.upsert"),
11271142 commandId: CommandId,
......@@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([
11731188 ThreadSessionSetCommand,
11741189 ThreadMessageAssistantDeltaCommand,
11751190 ThreadMessageAssistantCompleteCommand,
1191 ThreadHistoryImportCommand,
11761192 ThreadProposedPlanUpsertCommand,
11771193 ThreadTurnDiffCompleteCommand,
11781194 ThreadActivityAppendCommand,
......@@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({
14731489 adapterKey: Schema.optional(TrimmedNonEmptyString),
14741490 requestId: Schema.optional(ApprovalRequestId),
14751491 ingestedAt: Schema.optional(IsoDateTime),
1492 historyImport: Schema.optional(Schema.Boolean),
14761493 origin: Schema.optional(OrchestrationClientOrigin),
14771494});
14781495export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;
packages/contracts/src/rpc.ts+30
......@@ -28,6 +28,15 @@ import {
2828 FilesystemBrowseResult,
2929 FilesystemBrowseError,
3030} from "./filesystem.ts";
31import {
32 AgentSessionImportInput,
33 AgentSessionImportProjectChangedError,
34 AgentSessionImportProjectNotFoundError,
35 AgentSessionImportResult,
36 AgentSessionScanInput,
37 AgentSessionScanResult,
38 AgentSessionScanError,
39} from "./agentSessions.ts";
3140import {
3241 AssetAccessError,
3342 AssetCreateUrlInput,
......@@ -240,6 +249,8 @@ export const WS_METHODS = {
240249
241250 // Filesystem methods
242251 filesystemBrowse: "filesystem.browse",
252 agentSessionsScan: "agentSessions.scan",
253 agentSessionsImport: "agentSessions.import",
243254 assetsCreateUrl: "assets.createUrl",
244255 attachmentsCreateUploadUrl: "attachments.createUploadUrl",
245256 attachmentsDelete: "attachments.delete",
......@@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
823834 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
824835});
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
826854export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
827855 payload: AssetCreateUrlInput,
828856 success: AssetCreateUrlResult,
......@@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make(
12411269 WsProjectsWriteFileRpc,
12421270 WsShellOpenInEditorRpc,
12431271 WsFilesystemBrowseRpc,
1272 WsAgentSessionsScanRpc,
1273 WsAgentSessionsImportRpc,
12441274 WsAssetsCreateUrlRpc,
12451275 WsAttachmentsCreateUploadUrlRpc,
12461276 WsAttachmentsDeleteRpc,
packages/contracts/src/server.ts+3
......@@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({
748748 environment: ExecutionEnvironmentDescriptor,
749749 cwd: TrimmedNonEmptyString,
750750 projectName: TrimmedNonEmptyString,
751 bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])),
751752 bootstrapProjectId: Schema.optional(ProjectId),
752753 bootstrapThreadId: Schema.optional(ThreadId),
754 bootstrapProjectCreated: Schema.optional(Schema.Boolean),
755 bootstrapThreadCreated: Schema.optional(Schema.Boolean),
753756});
754757export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type;
755758
packages/contracts/src/settings.ts+8
......@@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({
286286 // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes);
287287 // disabling restores the platform's heavier default. No effect off macOS.
288288 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 ),
289296 // Model favorites. Historically keyed by provider kind, now
290297 // widened to `ProviderInstanceId` so users can favorite a specific model
291298 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
......@@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({
11881195 diffLayout: Schema.optionalKey(DiffLayout),
11891196 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
11901197 glassOpacity: Schema.optionalKey(GlassOpacity),
1198 onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)),
11911199 fontSizeInterface: Schema.optionalKey(InterfaceFontSize),
11921200 fontSizePrompt: Schema.optionalKey(PromptFontSize),
11931201 fontSizeCode: Schema.optionalKey(CodeFontSize),
packages/contracts/src/terminal.test.ts+43
......@@ -7,12 +7,18 @@ import {
77 TerminalClearInput,
88 TerminalCloseInput,
99 TerminalEvent,
10 TerminalError,
1011 TerminalOpenInput,
12 TerminalProviderEnvironmentError,
1113 TerminalResizeInput,
1214 TerminalSessionSnapshot,
1315 TerminalThreadInput,
1416 TerminalWriteInput,
1517} from "./terminal.ts";
18import { ProviderInstanceId } from "./providerInstance.ts";
19
20const encodeTerminalError = Schema.encodeUnknownSync(TerminalError);
21const decodeTerminalError = Schema.decodeUnknownSync(TerminalError);
1622
1723function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> {
1824 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 {
2733 }
2834}
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
3058describe("TerminalOpenInput", () => {
3159 it("accepts valid open input", () => {
3260 expect(
......@@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => {
87115 T3CODE_PROJECT_ROOT: "/tmp/project",
88116 CUSTOM_FLAG: "1",
89117 },
118 providerInstanceId: "codex_work",
90119 });
91120 expect(parsed.env).toMatchObject({
92121 T3CODE_PROJECT_ROOT: "/tmp/project",
93122 CUSTOM_FLAG: "1",
94123 });
95124 expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a");
125 expect(parsed.providerInstanceId).toBe("codex_work");
96126 });
97127
98128 it("rejects invalid env keys", () => {
......@@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => {
108138 }),
109139 ).toBe(false);
110140 });
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 });
111154});
112155
113156describe("TerminalAttachInput", () => {
packages/contracts/src/terminal.ts+32-3
......@@ -1,5 +1,6 @@
11import * as Schema from "effect/Schema";
22import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ProviderInstanceId } from "./providerInstance.ts";
34
45/**
56 * Client-side id for the first shell opened on a thread. Ids are uniformly
......@@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({
4344 cols: Schema.optional(TerminalColsSchema),
4445 rows: Schema.optional(TerminalRowsSchema),
4546 env: Schema.optional(TerminalEnvSchema),
47 providerInstanceId: Schema.optional(ProviderInstanceId),
4648});
47export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;
49export type TerminalOpenInput = typeof TerminalOpenInput.Type;
4850
4951export const TerminalAttachInput = Schema.Struct({
5052 ...TerminalSessionInput.fields,
......@@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({
5355 cols: Schema.optional(TerminalColsSchema),
5456 rows: Schema.optional(TerminalRowsSchema),
5557 env: Schema.optional(TerminalEnvSchema),
58 providerInstanceId: Schema.optional(ProviderInstanceId),
5659 restartIfNotRunning: Schema.optional(Schema.Boolean),
5760});
58export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>;
61export type TerminalAttachInput = typeof TerminalAttachInput.Type;
5962
6063export const TerminalWriteInput = Schema.Struct({
6164 ...TerminalSessionInput.fields,
......@@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({
8083 cols: TerminalColsSchema,
8184 rows: TerminalRowsSchema,
8285 env: Schema.optional(TerminalEnvSchema),
86 providerInstanceId: Schema.optional(ProviderInstanceId),
8387});
84export type TerminalRestartInput = Schema.Codec.Encoded<typeof TerminalRestartInput>;
88export type TerminalRestartInput = typeof TerminalRestartInput.Type;
8589
8690export const TerminalCloseInput = Schema.Struct({
8791 ...TerminalThreadInput.fields,
......@@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass<Terminal
299303 }
300304}
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
302329export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(
303330 "TerminalNotRunningError",
304331 {
......@@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([
345372 TerminalCwdError,
346373 TerminalHistoryError,
347374 TerminalSessionLookupError,
375 TerminalProviderInstanceNotFoundError,
376 TerminalProviderEnvironmentError,
348377 TerminalNotRunningError,
349378 TerminalWriteError,
350379 TerminalResizeError,
packages/shared/package.json+4
......@@ -270,6 +270,10 @@
270270 "./hostClassification": {
271271 "types": "./src/hostClassification.ts",
272272 "import": "./src/hostClassification.ts"
273 },
274 "./dateTime": {
275 "types": "./src/dateTime.ts",
276 "import": "./src/dateTime.ts"
273277 }
274278 },
275279 "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}