authorgravatar for ishansachu1@gmail.comIshan <ishansachu1@gmail.com> 2026-07-22 19:24:55+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 15:54:55+02:00
log783692afc10dd63693b3f65ca334322762293e67
tree4d9be29ec99782a77ff0ba2fa7ea154fc6ab53aa
parentaa5ec8036451a8dcf16d93294c69f524907c42c8
signature Signed by PGP key B5690EEEBB952194

Preserve connecting status while a turn starts (#4101)

Co-authored-by: codex <codex@users.noreply.github.com>

7 files changed, 475 insertions(+), 22 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+68
......@@ -2464,6 +2464,74 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
24642464 );
24652465});
24662466
2467it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-"))(
2468 "OrchestrationProjectionPipeline pending turn cleanup",
2469 (it) => {
2470 it.effect("clears pending turn starts when startup reaches a terminal session state", () =>
2471 Effect.gen(function* () {
2472 const projectionPipeline = yield* OrchestrationProjectionPipeline;
2473 const eventStore = yield* OrchestrationEventStore;
2474 const sql = yield* SqlClient.SqlClient;
2475
2476 for (const [index, status] of (["error", "interrupted", "stopped"] as const).entries()) {
2477 const threadId = ThreadId.make(`thread-terminal-${status}`);
2478 const requestedAt = `2026-02-26T14:00:0${index}.000Z`;
2479 yield* eventStore.append({
2480 type: "thread.turn-start-requested",
2481 eventId: EventId.make(`evt-terminal-pending-${status}`),
2482 aggregateKind: "thread",
2483 aggregateId: threadId,
2484 occurredAt: requestedAt,
2485 commandId: CommandId.make(`cmd-terminal-pending-${status}`),
2486 causationEventId: null,
2487 correlationId: CorrelationId.make(`cmd-terminal-pending-${status}`),
2488 metadata: {},
2489 payload: {
2490 threadId,
2491 messageId: MessageId.make(`message-terminal-${status}`),
2492 runtimeMode: "approval-required",
2493 createdAt: requestedAt,
2494 },
2495 });
2496 yield* eventStore.append({
2497 type: "thread.session-set",
2498 eventId: EventId.make(`evt-terminal-session-${status}`),
2499 aggregateKind: "thread",
2500 aggregateId: threadId,
2501 occurredAt: requestedAt,
2502 commandId: CommandId.make(`cmd-terminal-session-${status}`),
2503 causationEventId: null,
2504 correlationId: CorrelationId.make(`cmd-terminal-session-${status}`),
2505 metadata: {},
2506 payload: {
2507 threadId,
2508 session: {
2509 threadId,
2510 status,
2511 providerName: "codex",
2512 runtimeMode: "approval-required",
2513 activeTurnId: null,
2514 lastError: status === "error" ? "startup failed" : null,
2515 updatedAt: requestedAt,
2516 },
2517 },
2518 });
2519 }
2520
2521 yield* projectionPipeline.bootstrap;
2522
2523 const pendingRows = yield* sql<{ readonly threadId: string }>`
2524 SELECT thread_id AS "threadId"
2525 FROM projection_turns
2526 WHERE turn_id IS NULL
2527 AND state = 'pending'
2528 `;
2529 assert.deepEqual(pendingRows, []);
2530 }),
2531 );
2532 },
2533);
2534
24672535it.effect("restores pending turn-start metadata across projection pipeline restart", () =>
24682536 Effect.gen(function* () {
24692537 const { dbPath } = yield* ServerConfig;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+9
......@@ -1059,6 +1059,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
10591059 case "thread.session-set": {
10601060 const turnId = event.payload.session.activeTurnId;
10611061 if (turnId === null || event.payload.session.status !== "running") {
1062 if (
1063 event.payload.session.status === "error" ||
1064 event.payload.session.status === "stopped" ||
1065 event.payload.session.status === "interrupted"
1066 ) {
1067 yield* projectionTurnRepository.deletePendingTurnStartByThreadId({
1068 threadId: event.payload.threadId,
1069 });
1070 }
10621071 // Leaving the "running" session status is the turn-end signal:
10631072 // settle still-running turns so their duration reflects the whole
10641073 // turn rather than the last assistant message.
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+123-2
......@@ -22,6 +22,7 @@ import {
2222 TurnId,
2323} from "@t3tools/contracts";
2424import * as Effect from "effect/Effect";
25import * as Deferred from "effect/Deferred";
2526import * as Exit from "effect/Exit";
2627import * as Layer from "effect/Layer";
2728import * as ManagedRuntime from "effect/ManagedRuntime";
......@@ -145,6 +146,9 @@ describe("ProviderCommandReactor", () => {
145146 readonly threadModelSelection?: ModelSelection;
146147 readonly sessionModelSwitch?: "unsupported" | "in-session";
147148 readonly requiresNewThreadForModelChange?: boolean;
149 readonly startSessionEffect?: (
150 session: ProviderSession,
151 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
148152 }) {
149153 const now = "2026-01-01T00:00:00.000Z";
150154 const baseDir =
......@@ -159,6 +163,7 @@ describe("ProviderCommandReactor", () => {
159163 instanceId: ProviderInstanceId.make("codex"),
160164 model: "gpt-5-codex",
161165 };
166 const startSessionEffect = input?.startSessionEffect;
162167 const startSession = vi.fn((_: unknown, input: unknown) => {
163168 const sessionIndex = nextSessionIndex++;
164169 const resumeCursor =
......@@ -212,8 +217,13 @@ describe("ProviderCommandReactor", () => {
212217 createdAt: now,
213218 updatedAt: now,
214219 };
215 runtimeSessions.push(session);
216 return Effect.succeed(session);
220 return (startSessionEffect?.(session) ?? Effect.succeed(session)).pipe(
221 Effect.tap((startedSession) =>
222 Effect.sync(() => {
223 runtimeSessions.push(startedSession);
224 }),
225 ),
226 );
217227 });
218228 const sendTurn = vi.fn((_: unknown) =>
219229 Effect.succeed({
......@@ -463,9 +473,120 @@ describe("ProviderCommandReactor", () => {
463473 const readModel = await harness.readModel();
464474 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
465475 expect(thread?.session?.threadId).toBe("thread-1");
476 expect(thread?.session?.status).toBe("starting");
466477 expect(thread?.session?.runtimeMode).toBe("approval-required");
467478 });
468479
480 effectIt.effect("projects starting before a slow provider session finishes", () =>
481 Effect.gen(function* () {
482 const releaseStart = yield* Deferred.make<void>();
483 const harness = yield* Effect.promise(() =>
484 createHarness({
485 startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)),
486 }),
487 );
488 const now = "2026-01-01T00:00:00.000Z";
489
490 yield* harness.engine.dispatch({
491 type: "thread.turn.start",
492 commandId: CommandId.make("cmd-turn-start-slow-provider"),
493 threadId: ThreadId.make("thread-1"),
494 message: {
495 messageId: asMessageId("user-message-slow-provider"),
496 role: "user",
497 text: "start slowly",
498 attachments: [],
499 },
500 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
501 runtimeMode: "approval-required",
502 createdAt: now,
503 });
504
505 yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1));
506 const duringStartup = yield* Effect.promise(() => harness.readModel());
507 expect(
508 duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session
509 ?.status,
510 ).toBe("starting");
511 expect(harness.sendTurn).not.toHaveBeenCalled();
512
513 yield* Deferred.succeed(releaseStart, undefined);
514 yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));
515 }),
516 );
517
518 effectIt.effect("settles a failed provider startup and allows a clean retry", () =>
519 Effect.gen(function* () {
520 let failStartup = true;
521 const harness = yield* Effect.promise(() =>
522 createHarness({
523 startSessionEffect: (session) =>
524 failStartup
525 ? Effect.fail(
526 new ProviderAdapterRequestError({
527 provider: "codex",
528 method: "thread.start",
529 detail: "deterministic startup failure",
530 }),
531 )
532 : Effect.succeed(session),
533 }),
534 );
535 const now = "2026-01-01T00:00:00.000Z";
536
537 yield* harness.engine.dispatch({
538 type: "thread.turn.start",
539 commandId: CommandId.make("cmd-turn-start-provider-failure"),
540 threadId: ThreadId.make("thread-1"),
541 message: {
542 messageId: asMessageId("user-message-provider-failure"),
543 role: "user",
544 text: "fail once",
545 attachments: [],
546 },
547 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
548 runtimeMode: "approval-required",
549 createdAt: now,
550 });
551
552 yield* Effect.promise(() =>
553 waitFor(async () => {
554 const readModel = await harness.readModel();
555 return (
556 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session
557 ?.status === "error"
558 );
559 }),
560 );
561 let readModel = yield* Effect.promise(() => harness.readModel());
562 let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
563 expect(thread?.session?.lastError).toContain("deterministic startup failure");
564 expect(harness.sendTurn).not.toHaveBeenCalled();
565
566 failStartup = false;
567 yield* harness.engine.dispatch({
568 type: "thread.turn.start",
569 commandId: CommandId.make("cmd-turn-start-provider-retry"),
570 threadId: ThreadId.make("thread-1"),
571 message: {
572 messageId: asMessageId("user-message-provider-retry"),
573 role: "user",
574 text: "retry",
575 attachments: [],
576 },
577 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
578 runtimeMode: "approval-required",
579 createdAt: "2026-01-01T00:00:01.000Z",
580 });
581
582 yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));
583 readModel = yield* Effect.promise(() => harness.readModel());
584 thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
585 expect(thread?.session?.status).toBe("starting");
586 expect(thread?.session?.lastError).toBeNull();
587 }),
588 );
589
469590 it("generates a thread title on the first turn", async () => {
470591 const harness = await createHarness();
471592 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+34-10
......@@ -288,15 +288,20 @@ const make = Effect.gen(function* () {
288288 readonly createdAt: string;
289289 }) {
290290 const thread = yield* resolveThread(input.threadId);
291 const session = thread?.session;
292 if (!session) {
291 if (!thread) {
293292 return;
294293 }
294 const session = thread.session;
295295 yield* setThreadSession({
296296 threadId: input.threadId,
297297 session: {
298 ...session,
299 status: session.status === "stopped" ? "stopped" : "ready",
298 ...(session ?? {
299 threadId: input.threadId,
300 providerName: null,
301 providerInstanceId: thread.modelSelection.instanceId,
302 runtimeMode: thread.runtimeMode,
303 }),
304 status: session?.status === "stopped" ? "stopped" : "error",
300305 activeTurnId: null,
301306 lastError: input.detail,
302307 updatedAt: input.createdAt,
......@@ -354,6 +359,7 @@ const make = Effect.gen(function* () {
354359 createdAt: string,
355360 options?: {
356361 readonly modelSelection?: ModelSelection;
362 readonly pendingTurnStart?: boolean;
357363 },
358364 ) {
359365 const thread = yield* resolveThread(threadId);
......@@ -428,6 +434,22 @@ const make = Effect.gen(function* () {
428434 });
429435 }
430436 const preferredProvider: ProviderDriverKind = desiredDriverKind;
437 if (options?.pendingTurnStart === true && thread.session?.status !== "running") {
438 yield* setThreadSession({
439 threadId,
440 session: {
441 threadId,
442 status: "starting",
443 providerName: activeSession?.provider ?? preferredProvider,
444 providerInstanceId: activeSession?.providerInstanceId ?? desiredInstanceId,
445 runtimeMode: desiredRuntimeMode,
446 activeTurnId: null,
447 lastError: null,
448 updatedAt: createdAt,
449 },
450 createdAt,
451 });
452 }
431453 if (thread.session !== null) {
432454 yield* rejectStartedThreadModelChangeIfRequired({
433455 threadId,
......@@ -498,7 +520,10 @@ const make = Effect.gen(function* () {
498520 threadId,
499521 session: {
500522 threadId,
501 status: mapProviderSessionStatusToOrchestrationStatus(session.status),
523 status:
524 options?.pendingTurnStart === true && session.status === "ready"
525 ? "starting"
526 : mapProviderSessionStatusToOrchestrationStatus(session.status),
502527 providerName: session.provider,
503528 providerInstanceId: session.providerInstanceId,
504529 runtimeMode: desiredRuntimeMode,
......@@ -597,11 +622,10 @@ const make = Effect.gen(function* () {
597622 new Error(`Thread '${input.threadId}' was not found in read model.`),
598623 );
599624 }
600 yield* ensureSessionForThread(
601 input.threadId,
602 input.createdAt,
603 input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {},
604 );
625 yield* ensureSessionForThread(input.threadId, input.createdAt, {
626 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
627 pendingTurnStart: true,
628 });
605629 if (input.modelSelection !== undefined) {
606630 threadModelSelections.set(input.threadId, input.modelSelection);
607631 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+180
......@@ -30,6 +30,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";
3030import * as PubSub from "effect/PubSub";
3131import * as Scope from "effect/Scope";
3232import * as Stream from "effect/Stream";
33import { it as effectIt } from "@effect/vitest";
3334import { afterEach, describe, expect, it } from "vite-plus/test";
3435
3536import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
......@@ -493,6 +494,185 @@ describe("ProviderRuntimeIngestion", () => {
493494 expect(thread.session?.lastError).toBeNull();
494495 });
495496
497 effectIt.effect(
498 "keeps a reconnecting pending turn starting while ready clears stale active state",
499 () =>
500 Effect.gen(function* () {
501 const harness = yield* Effect.promise(() => createHarness());
502 const threadId = asThreadId("thread-1");
503 const staleTurnId = asTurnId("turn-stale-before-reconnect");
504
505 yield* harness.engine.dispatch({
506 type: "thread.turn.start",
507 commandId: CommandId.make("cmd-turn-start-pending-reconnect"),
508 threadId,
509 message: {
510 messageId: MessageId.make("message-pending-reconnect"),
511 role: "user",
512 text: "resume after reconnect",
513 attachments: [],
514 },
515 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
516 runtimeMode: "approval-required",
517 createdAt: "2026-01-01T00:00:01.000Z",
518 });
519 yield* harness.engine.dispatch({
520 type: "thread.session.set",
521 commandId: CommandId.make("cmd-session-starting-pending-reconnect"),
522 threadId,
523 session: {
524 threadId,
525 status: "starting",
526 providerName: "codex",
527 runtimeMode: "approval-required",
528 activeTurnId: staleTurnId,
529 lastError: null,
530 updatedAt: "2026-01-01T00:00:01.000Z",
531 },
532 createdAt: "2026-01-01T00:00:01.000Z",
533 });
534
535 harness.emit({
536 type: "session.state.changed",
537 eventId: asEventId("evt-session-ready-pending-reconnect"),
538 provider: ProviderDriverKind.make("codex"),
539 threadId,
540 createdAt: "2026-01-01T00:00:02.000Z",
541 payload: { state: "ready" },
542 });
543
544 let thread = yield* Effect.promise(() =>
545 waitForThread(
546 harness.readModel,
547 (entry) => entry.session?.status === "starting" && entry.session.activeTurnId === null,
548 ),
549 );
550 expect(thread.session?.status).toBe("starting");
551 expect(thread.session?.activeTurnId).toBeNull();
552
553 harness.emit({
554 type: "session.started",
555 eventId: asEventId("evt-session-started-pending-reconnect"),
556 provider: ProviderDriverKind.make("codex"),
557 threadId,
558 createdAt: "2026-01-01T00:00:03.000Z",
559 });
560 yield* Effect.promise(() => harness.drain());
561 thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
562 (entry) => entry.id === threadId,
563 )!;
564 expect(thread.session?.status).toBe("starting");
565 expect(thread.session?.activeTurnId).toBeNull();
566
567 harness.emit({
568 type: "turn.started",
569 eventId: asEventId("evt-turn-started-pending-reconnect"),
570 provider: ProviderDriverKind.make("codex"),
571 threadId,
572 turnId: asTurnId("turn-after-reconnect"),
573 createdAt: "2026-01-01T00:00:04.000Z",
574 });
575 thread = yield* Effect.promise(() =>
576 waitForThread(
577 harness.readModel,
578 (entry) =>
579 entry.session?.status === "running" &&
580 entry.session.activeTurnId === asTurnId("turn-after-reconnect"),
581 ),
582 );
583 expect(thread.session?.status).toBe("running");
584
585 harness.emit({
586 type: "session.started",
587 eventId: asEventId("evt-session-started-duplicate-midturn"),
588 provider: ProviderDriverKind.make("codex"),
589 threadId,
590 createdAt: "2026-01-01T00:00:05.000Z",
591 });
592 yield* Effect.promise(() => harness.drain());
593 thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
594 (entry) => entry.id === threadId,
595 )!;
596 expect(thread.session?.status).toBe("running");
597 expect(thread.session?.activeTurnId).toBe(asTurnId("turn-after-reconnect"));
598 }),
599 );
600
601 effectIt.effect("keeps an aborted pending start stopped across duplicate exit events", () =>
602 Effect.gen(function* () {
603 const harness = yield* Effect.promise(() => createHarness());
604 const threadId = asThreadId("thread-1");
605 const stoppedAt = "2026-01-01T00:00:02.000Z";
606
607 yield* harness.engine.dispatch({
608 type: "thread.turn.start",
609 commandId: CommandId.make("cmd-turn-start-before-stop"),
610 threadId,
611 message: {
612 messageId: MessageId.make("message-before-stop"),
613 role: "user",
614 text: "stop this startup",
615 attachments: [],
616 },
617 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
618 runtimeMode: "approval-required",
619 createdAt: "2026-01-01T00:00:01.000Z",
620 });
621 yield* harness.engine.dispatch({
622 type: "thread.session.set",
623 commandId: CommandId.make("cmd-session-starting-before-stop"),
624 threadId,
625 session: {
626 threadId,
627 status: "starting",
628 providerName: "codex",
629 runtimeMode: "approval-required",
630 activeTurnId: null,
631 lastError: null,
632 updatedAt: "2026-01-01T00:00:01.000Z",
633 },
634 createdAt: "2026-01-01T00:00:01.000Z",
635 });
636 yield* harness.engine.dispatch({
637 type: "thread.session.set",
638 commandId: CommandId.make("cmd-session-stop-pending-start"),
639 threadId,
640 session: {
641 threadId,
642 status: "stopped",
643 providerName: "codex",
644 runtimeMode: "approval-required",
645 activeTurnId: null,
646 lastError: null,
647 updatedAt: stoppedAt,
648 },
649 createdAt: stoppedAt,
650 });
651
652 harness.emit({
653 type: "session.exited",
654 eventId: asEventId("evt-session-exited-after-stop"),
655 provider: ProviderDriverKind.make("codex"),
656 threadId,
657 createdAt: "2026-01-01T00:00:03.000Z",
658 });
659 harness.emit({
660 type: "session.exited",
661 eventId: asEventId("evt-duplicate-session-exited-after-stop"),
662 provider: ProviderDriverKind.make("codex"),
663 threadId,
664 createdAt: "2026-01-01T00:00:04.000Z",
665 });
666
667 yield* Effect.promise(() => harness.drain());
668 const thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
669 (entry) => entry.id === threadId,
670 );
671 expect(thread?.session?.status).toBe("stopped");
672 expect(thread?.session?.activeTurnId).toBeNull();
673 }),
674 );
675
496676 it("does not clear active turn when session/thread started arrives mid-turn", async () => {
497677 const harness = await createHarness();
498678 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+16-10
......@@ -1308,6 +1308,11 @@ const make = Effect.gen(function* () {
13081308 const now = event.createdAt;
13091309 const eventTurnId = toTurnId(event.turnId);
13101310 const activeTurnId = thread.session?.activeTurnId ?? null;
1311 const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
1312 threadId: thread.id,
1313 });
1314 const hasPendingTurnStart =
1315 Option.isSome(pendingTurnStart) && thread.session?.status === "starting";
13111316
13121317 const conflictsWithActiveTurn =
13131318 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
......@@ -1322,11 +1327,7 @@ const make = Effect.gen(function* () {
13221327 const conflictingTurnStartIsPendingTurnStart =
13231328 event.type === "turn.started" && conflictsWithActiveTurn
13241329 ? sameId(yield* getExpectedProviderTurnIdForThread(thread.id), eventTurnId) &&
1325 Option.isSome(
1326 yield* projectionTurnRepository.getPendingTurnStartByThreadId({
1327 threadId: thread.id,
1328 }),
1329 )
1330 Option.isSome(pendingTurnStart)
13301331 : false;
13311332
13321333 const shouldApplyThreadLifecycle = (() => {
......@@ -1370,8 +1371,10 @@ const make = Effect.gen(function* () {
13701371 ) {
13711372 const status = (() => {
13721373 switch (event.type) {
1373 case "session.state.changed":
1374 return orchestrationSessionStatusFromRuntimeState(event.payload.state);
1374 case "session.state.changed": {
1375 const runtimeStatus = orchestrationSessionStatusFromRuntimeState(event.payload.state);
1376 return hasPendingTurnStart && runtimeStatus === "ready" ? "starting" : runtimeStatus;
1377 }
13751378 case "turn.started":
13761379 return "running";
13771380 case "session.exited":
......@@ -1383,8 +1386,8 @@ const make = Effect.gen(function* () {
13831386 case "session.started":
13841387 case "thread.started":
13851388 // Provider thread/session start notifications can arrive during an
1386 // active turn; preserve turn-running state in that case.
1387 return activeTurnId !== null ? "running" : "ready";
1389 // active or pending turn; preserve that lifecycle state.
1390 return activeTurnId !== null ? "running" : hasPendingTurnStart ? "starting" : "ready";
13881391 }
13891392 })();
13901393 const nextActiveTurnId =
......@@ -1392,7 +1395,10 @@ const make = Effect.gen(function* () {
13921395 ? (eventTurnId ?? null)
13931396 : event.type === "turn.completed" || event.type === "session.exited"
13941397 ? null
1395 : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status)
1398 : event.type === "session.state.changed" &&
1399 !sessionStatusAllowsActiveTurn(
1400 orchestrationSessionStatusFromRuntimeState(event.payload.state),
1401 )
13961402 ? null
13971403 : activeTurnId;
13981404 const lastError =
packages/client-runtime/src/state/threadSettled.test.ts+45
......@@ -178,6 +178,51 @@ describe("effectiveSettled", () => {
178178 ).toBe(false);
179179 });
180180
181 it("keeps a new turn active from queued through starting and running", () => {
182 const requestedAt = "2026-04-09T12:00:00.000Z";
183 const transitionNow = "2026-04-09T12:00:30.000Z";
184 const base = makeShell({
185 settledOverride: null,
186 activityAt: STALE,
187 });
188 const queued: OrchestrationThreadShell = {
189 ...base,
190 latestUserMessageAt: requestedAt,
191 latestTurn: null,
192 session: null,
193 };
194 const starting: OrchestrationThreadShell = {
195 ...queued,
196 session: {
197 threadId: queued.id,
198 status: "starting",
199 providerName: "Codex",
200 runtimeMode: "full-access",
201 activeTurnId: null,
202 lastError: null,
203 updatedAt: requestedAt,
204 },
205 };
206 const running: OrchestrationThreadShell = {
207 ...starting,
208 session: {
209 ...starting.session!,
210 status: "running",
211 activeTurnId: TurnId.make("turn-new"),
212 },
213 };
214
215 for (const shell of [queued, starting, running]) {
216 expect(
217 effectiveSettled(shell, {
218 now: transitionNow,
219 autoSettleAfterDays: 3,
220 changeRequestState: "merged",
221 }),
222 ).toBe(false);
223 }
224 });
225
181226 it("uses a strict inactivity boundary and honors a null threshold", () => {
182227 const boundary = makeShell({
183228 activityAt: "2026-04-07T00:00:00.000Z",