diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc..0242b3ad4782 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -441,6 +441,9 @@ function deriveWorkLogEntries( if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + // Delivery-only nudge for the post-start observation; carries no user + // content and must never render as a work-log row. + if (activity.kind === "post-start-observation") continue; if (activity.summary === "Checkpoint captured") continue; if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 0df54be2f701..a1b3d26a028e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -54,6 +54,7 @@ import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../src/orchestration/ThreadPostStartActivity.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; @@ -317,6 +318,7 @@ export const makeOrchestrationIntegrationHarness = ( ).pipe( Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provideMerge(ThreadPlanProgress.layer), + Layer.provideMerge(ThreadPostStartActivity.layer), ); const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1d0b3da1bdbc..09bfc17598bd 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -47,6 +47,7 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { RuntimeReceiptBusTest } from "./RuntimeReceiptBus.ts"; import * as RuntimeReceiptBus from "../Services/RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -326,6 +327,7 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -335,6 +337,7 @@ describe("CheckpointReactor", () => { const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 078750967471..cb4d92533e1a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -47,6 +47,7 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, @@ -79,6 +80,7 @@ function makeOrchestrationLayer( ).pipe( Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide( @@ -1514,6 +1516,8 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1622,6 +1626,8 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1771,6 +1777,8 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 179d04843c7e..f3ea5d988af3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -42,6 +42,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -4358,6 +4359,7 @@ const engineLayer = it.layer( Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provideMerge(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 843eb8343d84..77eb34acdb58 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -27,6 +27,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; @@ -52,6 +53,7 @@ it.effect("reads project shells without loading threads or resolving excluded pr const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (root) => @@ -103,6 +105,7 @@ const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -628,6 +631,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { hasActionableProposedPlan: false, backgroundLiveness: null, planProgress: null, + postStartActivity: null, }, ]); @@ -2404,6 +2408,8 @@ it.effect( const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provideMerge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (cwd: string) => @@ -3452,6 +3458,7 @@ it.effect("omits foreign-host PRs from legacy snapshots while preserving native const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1e7058742e25..4d220b7f798b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,7 @@ import { } from "@t3tools/contracts"; import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests"; import * as Arr from "effect/Array"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -55,6 +56,7 @@ import { } from "../../persistence/Errors.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; +import { ThreadPostStartActivityService } from "../ThreadPostStartActivity.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; @@ -493,9 +495,17 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const threadPlanProgress = yield* ThreadPlanProgressService; + const threadPostStartActivity = yield* ThreadPostStartActivityService; const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; + // Live observation is stamped with the server clock at mapping time so the + // client can measure provider ages against the server's clock rather than + // assuming its own agrees. + const readPostStartActivity = (threadId: string, observedAt: string) => { + const state = threadPostStartActivity.getThreadPostStartActivity(threadId); + return state === null ? null : { ...state, observedAt }; + }; const resolveRepositoryIdentitiesForProjects = Effect.fn( "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", )(function* ( @@ -2703,6 +2713,7 @@ pending_approval_requests AS ( sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), ); const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); + const postStartObservedAt = DateTime.formatIso(yield* DateTime.now); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), @@ -2753,6 +2764,7 @@ pending_approval_requests AS ( row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + postStartActivity: readPostStartActivity(row.threadId, postStartObservedAt), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2868,6 +2880,7 @@ pending_approval_requests AS ( const sessionByThread = new Map( sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), ); + const postStartObservedAt = DateTime.formatIso(yield* DateTime.now); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), @@ -2916,6 +2929,7 @@ pending_approval_requests AS ( row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + postStartActivity: readPostStartActivity(row.threadId, postStartObservedAt), })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -3231,6 +3245,8 @@ pending_approval_requests AS ( return Option.none(); } + const postStartObservedAt = DateTime.formatIso(yield* DateTime.now); + return Option.some({ id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -3272,6 +3288,7 @@ pending_approval_requests AS ( threadRow.value.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), + postStartActivity: readPostStartActivity(threadRow.value.threadId, postStartObservedAt), } satisfies OrchestrationThreadShell); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 19b3ba831ece..bee10a570cc7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -63,6 +63,7 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { providerErrorLabelFromInstanceHint, ProviderCommandReactorLive, @@ -408,6 +409,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -417,6 +419,7 @@ describe("ProviderCommandReactor", () => { const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d61739f72c21..b020be075337 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -10,6 +10,7 @@ import { ProviderRuntimeEvent, ProviderSession, ProviderInstanceId, + type OrchestrationEvent, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -58,6 +59,7 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { ProviderRuntimeIngestionLive, splitBufferedAssistantText, @@ -328,6 +330,7 @@ describe("ProviderRuntimeIngestion", () => { // engine, and the snapshot query (reader). Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provideMerge(ThreadPlanProgress.layer), + Layer.provideMerge(ThreadPostStartActivity.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), @@ -352,6 +355,21 @@ describe("ProviderRuntimeIngestion", () => { const ingestion = await testRuntime.runPromise(Effect.service(ProviderRuntimeIngestionService)); scope = await Effect.runPromise(Scope.make("sequential")); await testRuntime.runPromise(ingestion.start().pipe(Scope.provide(scope))); + // Mimic an already-open shell subscription: collect the domain events the + // ws shell stream would turn into a refetch, so delivery can be asserted + // without a fresh direct query. + const domainEvents: OrchestrationEvent[] = []; + await testRuntime.runPromise( + Effect.forkScoped( + engine.streamDomainEvents.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + domainEvents.push(event); + }), + ), + ), + ).pipe(Scope.provide(scope)), + ); const drain = () => testRuntime.runPromise(ingestion.drain); const dispatch = (command: OrchestrationCommand) => testRuntime.runPromise(engine.dispatch(command)); @@ -437,6 +455,7 @@ describe("ProviderRuntimeIngestion", () => { sqlCount: sqlCounter.count, setProviderSession: provider.setSession, drain, + domainEvents, }; } @@ -1359,27 +1378,510 @@ describe("ProviderRuntimeIngestion", () => { ); }); - it("ignores provider content deltas that cannot change thread state", async () => { + it("observes canonical command/file output without persisting transcript rows", async () => { const harness = await createHarness(); - const initial = await harness.readModel(); + const providerStamp = "2020-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-codex-output-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId, + turnId: asTurnId("turn-codex-output"), + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.activeTurnId === "turn-codex-output", + ); - for (const streamKind of ["command_output", "file_change_output"] as const) { + const before = harness.domainEvents.length; + harness.advanceClock(15_000); + for (const [streamKind, itemId] of [ + ["command_output", "cmd-item-1"], + ["file_change_output", "file-item-1"], + ] as const) { harness.emit({ type: "content.delta", - eventId: asEventId(`evt-ignored-${streamKind}`), + eventId: asEventId(`evt-codex-output-${streamKind}`), provider: ProviderDriverKind.make("codex"), - createdAt: "2026-01-01T00:00:00.000Z", - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-ignored"), - payload: { - streamKind, - delta: "ignored output", - }, + createdAt: providerStamp, + threadId, + turnId: asTurnId("turn-codex-output"), + itemId: asItemId(itemId), + payload: { streamKind, delta: "canonical output" }, }); } + await harness.drain(); + + // These deltas must not become transcript/output rows. + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + const transcriptRows = (thread?.activities ?? []).filter( + (activity) => activity.kind !== "post-start-observation", + ); + expect(transcriptRows).toEqual([]); + + // But they are meaningful provider progress on the server clock, and the + // named command/file items advance as outstanding tools. + const shell = await harness.readThreadShell(); + expect(Date.parse(shell.postStartActivity?.lastProviderActivityAt ?? "")).toBeGreaterThan( + Date.parse(providerStamp), + ); + expect(shell.postStartActivity?.outstandingTools.map((tool) => tool.toolCallId)).toEqual([ + "cmd-item-1", + "file-item-1", + ]); + + // An already-open shell receives the delivery signal. + expect( + harness.domainEvents + .slice(before) + .some( + (event) => + event.type === "thread.activity-appended" && + event.payload.activity.kind === "post-start-observation", + ), + ).toBe(true); + }); + + it("observes Codex MCP tool progress identified by the event itemId", async () => { + const harness = await createHarness(); + const providerStamp = "2020-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-mcp-progress-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId, + turnId: asTurnId("turn-mcp-progress"), + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.activeTurnId === "turn-mcp-progress", + ); + + // Codex MCP progress carries only a summary and names its call via the + // event itemId, not payload.toolUseId. + harness.advanceClock(15_000); + harness.emit({ + type: "tool.progress", + eventId: asEventId("evt-mcp-progress"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId, + turnId: asTurnId("turn-mcp-progress"), + itemId: asItemId("mcp-call-1"), + payload: { summary: "Searching files" }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.activities.some((activity) => activity.kind === "tool.progress")).toBe(false); + const shell = await harness.readThreadShell(); + expect(shell.postStartActivity?.outstandingTools.map((tool) => tool.toolCallId)).toEqual([ + "mcp-call-1", + ]); + expect(Date.parse(shell.postStartActivity?.lastProviderActivityAt ?? "")).toBeGreaterThan( + Date.parse(providerStamp), + ); + }); + + it("observes assistant text on the server clock and exposes it on the shell", async () => { + const harness = await createHarness(); + // A provider part timestamp pinned far in the past: the observation must + // use the server clock, not this value. + const providerStamp = "2020-01-01T00:00:00.000Z"; + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-post-start-text"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-obs"), + payload: { streamKind: "assistant_text", delta: "streaming" }, + }); + await harness.drain(); + + const observed = (await harness.readThreadShell()).postStartActivity?.lastProviderActivityAt; + expect(observed).toBeDefined(); + expect(Date.parse(observed!)).toBeGreaterThan(Date.parse(providerStamp)); + + // Usage-only task metadata does not count as meaningful progress. + harness.advanceClock(60_000); + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-post-start-usage"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2020-01-01T00:01:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-obs"), + payload: { + taskId: "task-1", + description: "Token usage", + typedUsage: { totalTokens: 10, inputTokens: 10 }, + }, + }); + await harness.drain(); + expect((await harness.readThreadShell()).postStartActivity?.lastProviderActivityAt).toBe( + observed, + ); + }); + + it("delivers advancing observation to an already-open shell during buffered content", async () => { + const harness = await createHarness(); + const providerStamp = "2020-01-01T00:00:00.000Z"; + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-buffered-delivery-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-delivery"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session.activeTurnId === "turn-buffered-delivery", + ); + + const before = harness.domainEvents.length; + harness.advanceClock(15_000); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-buffered-delivery-content"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-delivery"), + payload: { streamKind: "assistant_text", delta: "buffered" }, + }); + await harness.drain(); + + // The subscribed shell receives a delivery signal even though turn-mode + // buffering persists no per-delta row. + const delivered = harness.domainEvents.slice(before); + expect( + delivered.some( + (event) => + event.type === "thread.activity-appended" && + event.payload.activity.kind === "post-start-observation", + ), + ).toBe(true); + + const shell = await harness.readThreadShell(); + const observedAt = shell.postStartActivity?.lastProviderActivityAt; + expect(observedAt).toBeDefined(); + expect(Date.parse(observedAt!)).toBeGreaterThan(Date.parse(providerStamp)); + }); + + it("observes parent tool heartbeats that carry no taskId", async () => { + const harness = await createHarness(); + const providerStamp = "2020-01-01T00:00:00.000Z"; + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-parent-heartbeat-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-parent-heartbeat"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session.activeTurnId === "turn-parent-heartbeat", + ); + + const before = harness.domainEvents.length; + harness.advanceClock(15_000); + harness.emit({ + type: "tool.progress", + eventId: asEventId("evt-parent-heartbeat"), + provider: ProviderDriverKind.make("codex"), + createdAt: providerStamp, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-parent-heartbeat"), + payload: { toolUseId: "parent-tool-1", toolName: "Bash", elapsedSeconds: 12 }, + }); + await harness.drain(); + + // The heartbeat is intentionally not persisted, but the observation and its + // delivery do not depend on persistence. + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === asThreadId("thread-1"), + ); + expect(thread?.activities.some((activity) => activity.kind === "tool.progress")).toBe(false); + + const shell = await harness.readThreadShell(); + expect(shell.postStartActivity?.outstandingTools.map((tool) => tool.toolCallId)).toEqual([ + "parent-tool-1", + ]); + expect( + harness.domainEvents + .slice(before) + .some( + (event) => + event.type === "thread.activity-appended" && + event.payload.activity.kind === "post-start-observation", + ), + ).toBe(true); + }); + + it("keeps the current turn's observation across a superseded turn's late events", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-supersede-turn-a"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-a"), + }); + await waitForThread(harness.readModel, (thread) => thread.session?.activeTurnId === "turn-a"); + + harness.advanceClock(15_000); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-supersede-a-content"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-a"), + payload: { streamKind: "assistant_text", delta: "a" }, + }); + await harness.drain(); + // Accepted supersession: the session now tracks turn B. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-supersede-session-b"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-b"), + updatedAt: now, + lastError: null, + }, + createdAt: now, + }); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-supersede-turn-b"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-b"), + }); + await waitForThread(harness.readModel, (thread) => thread.session?.activeTurnId === "turn-b"); + + harness.advanceClock(15_000); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-supersede-b-content"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-b"), + payload: { streamKind: "assistant_text", delta: "b" }, + }); await harness.drain(); - expect(await harness.readModel()).toEqual(initial); + const bObserved = (await harness.readThreadShell()).postStartActivity?.lastProviderActivityAt; + expect(bObserved).toBeDefined(); + + // Late A traffic must not refresh B. + harness.advanceClock(15_000); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-supersede-a-late"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-a"), + payload: { streamKind: "assistant_text", delta: "late-a" }, + }); + harness.emit({ + type: "tool.started", + eventId: asEventId("evt-supersede-a-tool"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-a"), + payload: { toolCallId: "stale-a-tool", title: "Stale" }, + }); + await harness.drain(); + const afterLateA = await harness.readThreadShell(); + expect(afterLateA.postStartActivity?.lastProviderActivityAt).toBe(bObserved); + expect(afterLateA.postStartActivity?.outstandingTools ?? []).toEqual([]); + + // A delayed A completion rejected by lifecycle logic must not clear B. + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-supersede-a-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-a"), + payload: { state: "completed" }, + }); + await harness.drain(); + expect((await harness.readThreadShell()).postStartActivity?.lastProviderActivityAt).toBe( + bObserved, + ); + + // B's own terminal event clears it. + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-supersede-b-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId, + turnId: asTurnId("turn-b"), + payload: { state: "completed" }, + }); + await harness.drain(); + expect((await harness.readThreadShell()).postStartActivity ?? null).toBeNull(); + }); + + it("anchors a pending request to itself when the ended turn's traffic arrives late", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const turnAStartedAt = "2026-01-01T00:00:00.000Z"; + + // Turn A runs and ends; terminal clearing removes its record. + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-pending-a-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: turnAStartedAt, + threadId, + turnId: asTurnId("turn-a"), + }); + await waitForThread(harness.readModel, (thread) => thread.session?.activeTurnId === "turn-a"); + harness.advanceClock(5 * 60_000); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-pending-a-content"), + provider: ProviderDriverKind.make("codex"), + createdAt: turnAStartedAt, + threadId, + turnId: asTurnId("turn-a"), + payload: { streamKind: "assistant_text", delta: "a" }, + }); + await harness.drain(); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-pending-a-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: turnAStartedAt, + threadId, + turnId: asTurnId("turn-a"), + payload: { state: "completed" }, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + await harness.drain(); + expect((await harness.readThreadShell()).postStartActivity ?? null).toBeNull(); + + // B is submitted through the real turn-start command seam; the provider + // start is deliberately held, so the session is `starting` with no + // activeTurnId and only the pending request identifies B. + harness.advanceClock(60_000); + const bRequestedAt = "2026-01-01T00:06:00.000Z"; + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-pending-submit-b"), + threadId, + message: { + messageId: asMessageId("pending-b-message"), + role: "user", + text: "B", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: bRequestedAt, + }); + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-pending-session-b-starting"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: bRequestedAt, + lastError: null, + }, + createdAt: bRequestedAt, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "starting" && thread.session?.activeTurnId === null, + ); + + // Ended-turn A content and tool traffic arrives while B is starting with no + // provider turn id. B must stay anchored to its own pending request: A's + // activity must not become B's recency and A's tool must not appear. + harness.advanceClock(4 * 60_000); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-pending-a-late-content"), + provider: ProviderDriverKind.make("codex"), + createdAt: turnAStartedAt, + threadId, + turnId: asTurnId("turn-a"), + payload: { streamKind: "assistant_text", delta: "late-a" }, + }); + harness.emit({ + type: "tool.started", + eventId: asEventId("evt-pending-a-late-tool"), + provider: ProviderDriverKind.make("codex"), + createdAt: turnAStartedAt, + threadId, + turnId: asTurnId("turn-a"), + payload: { toolCallId: "stale-a-tool", title: "Stale" }, + }); + await harness.drain(); + + const duringWindow = await harness.readThreadShell(); + // B keeps its own pending record: no A tools and no A activity timestamp, + // so its silence is anchored to its own request time (the +11m check from + // the review must not see a one-minute A age or A's tool). + expect(duringWindow.postStartActivity?.turnId ?? null).toBeNull(); + expect(duringWindow.postStartActivity?.outstandingTools ?? []).toEqual([]); + expect(duringWindow.postStartActivity?.lastProviderActivityAt ?? null).toBeNull(); + + // A delayed completion for the ended turn still must not erase B's pending + // ownership, so B remains anchored after it too. + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-pending-a-late-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: turnAStartedAt, + threadId, + turnId: asTurnId("turn-a"), + payload: { state: "completed" }, + }); + await harness.drain(); + + const shell = await harness.readThreadShell(); + expect(shell.postStartActivity?.turnId ?? null).toBeNull(); + expect(shell.postStartActivity?.outstandingTools ?? []).toEqual([]); + expect(shell.postStartActivity?.lastProviderActivityAt ?? null).toBeNull(); }); it("maps canonical content delta/item completed into finalized assistant messages", async () => { @@ -2717,7 +3219,11 @@ describe("ProviderRuntimeIngestion", () => { }, })), ); - expect(harness.sqlCount() - before).toBe(eventCount); + // Buffered deltas do no per-delta lifecycle write; delivery adds one + // bounded, coalesced post-start observation nudge for the whole batch. + const bufferedStatements = harness.sqlCount() - before; + expect(bufferedStatements).toBeGreaterThanOrEqual(eventCount); + expect(bufferedStatements).toBeLessThanOrEqual(eventCount + 16); const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0db70e491235..925e26236757 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -46,6 +46,7 @@ import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/La import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; +import { ThreadPostStartActivityService } from "../ThreadPostStartActivity.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionService, @@ -1019,6 +1020,7 @@ export function runtimeEventToActivities( const make = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const threadPlanProgress = yield* ThreadPlanProgressService; + const threadPostStartActivity = yield* ThreadPostStartActivityService; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -1034,6 +1036,51 @@ const make = Effect.gen(function* () { Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), ); + // Post-start delivery: buffered `turn`-mode content and parent tool + // heartbeats advance the in-memory observation without dispatching an + // activity row, so an already-subscribed shell never refetches and can + // falsely warn while generation continues. Nudge the existing activity/shell + // delivery path with a coalesced, user-invisible append, bounded per thread + // so it is never per-token or per-delta. + const POST_START_DELIVERY_INTERVAL_MS = 10_000; + const lastPostStartDeliveryAtMs = new Map(); + const nudgePostStartDelivery = ( + threadId: ThreadId, + event: ProviderRuntimeEvent, + turnId: TurnId | null, + observedAt: string, + ) => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + const last = lastPostStartDeliveryAtMs.get(threadId) ?? Number.NEGATIVE_INFINITY; + if (nowMs - last < POST_START_DELIVERY_INTERVAL_MS) return; + lastPostStartDeliveryAtMs.set(threadId, nowMs); + yield* orchestrationEngine + .dispatch({ + type: "thread.activity.append", + commandId: yield* providerCommandId(event, "post-start-observation"), + threadId, + activity: { + id: EventId.make(`post-start-observation:${threadId}`), + tone: "info", + kind: "post-start-observation", + summary: "Provider progress", + payload: {}, + turnId, + createdAt: observedAt, + }, + createdAt: observedAt, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("post-start observation delivery nudge failed", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, @@ -1757,13 +1804,19 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { - if ( - event.type === "content.delta" && - event.payload.streamKind !== "assistant_text" && - event.payload.streamKind !== "reasoning_text" && - event.payload.streamKind !== "reasoning_summary_text" - ) { - return; + // Assistant/reasoning text and canonical Codex command/file output are + // observed as provider progress below. Other content deltas (plan text, + // unknown stream kinds) cannot change thread state and return before + // hydration. + if (event.type === "content.delta") { + const streamKind = event.payload.streamKind; + const isText = + streamKind === "assistant_text" || + streamKind === "reasoning_text" || + streamKind === "reasoning_summary_text"; + const isCanonicalToolOutput = + streamKind === "command_output" || streamKind === "file_change_output"; + if (!isText && !isCanonicalToolOutput) return; } const thread = yield* resolveThreadRuntimeContext(event.threadId); @@ -1840,6 +1893,79 @@ const make = Effect.gen(function* () { ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) : null; + // Post-start observation. Record on the server clock before any dispatch + // so the live record and the delivered shell agree, and scope it to the + // current turn so a superseded turn's late traffic cannot refresh it. + // The observation is recorded for content text and ephemeral parent tool + // heartbeats (which never become persisted rows) as well as persisted + // activity rows. + const observedAt = DateTime.formatIso(yield* DateTime.now); + const observationTurnId = eventTurnId ?? activeTurnId ?? null; + const observationAllowed = !conflictsWithActiveTurn; + if (event.type === "turn.started" && shouldApplyThreadLifecycle) { + // A newly accepted turn supersedes the previous one: reset the record + // so the new turn cannot inherit outstanding tools or completion + // memory, and stale old-turn traffic is rejected by identity. + threadPostStartActivity.beginTurn(thread.id, eventTurnId ?? null); + } + if (observationAllowed) { + const streamKind = event.type === "content.delta" ? event.payload.streamKind : undefined; + const isContentProgress = + event.type === "content.delta" && + event.payload.delta.length > 0 && + (streamKind === "assistant_text" || + streamKind === "reasoning_text" || + streamKind === "reasoning_summary_text"); + // Canonical Codex command/file execution output is real progress even + // though it never becomes a persisted row. Observe it on the server + // clock and, when the event names the command/file item, advance that + // tool so its age keeps moving. + const isCanonicalToolOutput = + event.type === "content.delta" && + event.payload.delta.length > 0 && + (streamKind === "command_output" || streamKind === "file_change_output"); + // Parent-conversation tool heartbeats (Claude `tool.progress` with no + // taskId, Codex MCP progress carrying only a summary) are intentionally + // dropped from persisted activities; observe them directly so liveness + // does not depend on persistence. Correlation uses the canonical event + // item id when present, not one provider's payload alias. + const heartbeatToolId = event.itemId !== undefined ? String(event.itemId) : undefined; + const isEphemeralToolHeartbeat = + event.type === "tool.progress" && + (event.payload.toolUseId !== undefined || heartbeatToolId !== undefined); + if (isContentProgress) { + threadPostStartActivity.recordContentProgress(thread.id, observedAt, observationTurnId); + } + if (isCanonicalToolOutput) { + threadPostStartActivity.recordActivity( + thread.id, + observedAt, + { + kind: "tool.progress", + payload: heartbeatToolId !== undefined ? { toolCallId: heartbeatToolId } : {}, + }, + observationTurnId, + ); + } + if (isEphemeralToolHeartbeat) { + threadPostStartActivity.recordActivity( + thread.id, + observedAt, + { + kind: "tool.progress", + payload: { + ...event.payload, + ...(heartbeatToolId !== undefined ? { toolCallId: heartbeatToolId } : {}), + }, + }, + observationTurnId, + ); + } + if (isContentProgress || isCanonicalToolOutput || isEphemeralToolHeartbeat) { + yield* nudgePostStartDelivery(thread.id, event, observationTurnId, observedAt); + } + } + if ( event.type === "session.started" || event.type === "session.state.changed" || @@ -2579,6 +2705,11 @@ const make = Effect.gen(function* () { } const activities = runtimeEventToActivities(activityEvent, taskTitle); + // Persisted activity rows feed the same live observation, scoped to the + // turn the event named so a stale row cannot refresh a newer turn. + for (const activity of activities) { + threadPostStartActivity.recordActivity(thread.id, observedAt, activity, observationTurnId); + } yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => @@ -2592,9 +2723,28 @@ const make = Effect.gen(function* () { ), ), ).pipe(Effect.asVoid); + // The turn is over: drop the live observation so the settled shell does + // not keep advertising provider progress. Only the accepted lifecycle + // owner may clear: a delayed completion rejected for a superseded turn + // must not erase the current turn's evidence. + if (event.type === "session.exited") { + threadPostStartActivity.clearThread(thread.id); + } else if (isTerminalTurn && shouldApplyThreadLifecycle) { + threadPostStartActivity.clearThread(thread.id, observationTurnId); + } }); - const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; + // The request is accepted before the provider is sent: session is `starting` + // with `activeTurnId` null. Anchor post-start ownership here so late traffic + // from the turn that just ended cannot recreate live evidence for the new + // request (it would otherwise look like the new request's own progress). + const processDomainEvent = (event: TurnStartRequestedDomainEvent) => + Effect.sync(() => { + threadPostStartActivity.beginPendingRequest( + event.payload.threadId, + String(event.payload.messageId), + ); + }); // Records a mid-turn placeholder checkpoint for a provider diff. Runs on the // lifecycle worker, after repository detection, so the running-turn check diff --git a/apps/server/src/orchestration/ThreadPostStartActivity.test.ts b/apps/server/src/orchestration/ThreadPostStartActivity.test.ts new file mode 100644 index 000000000000..58360ae56e7e --- /dev/null +++ b/apps/server/src/orchestration/ThreadPostStartActivity.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; + +import { make } from "./ThreadPostStartActivity.ts"; + +const T0 = "2026-01-01T00:00:00.000Z"; +const BASE = DateTime.makeUnsafe(T0); +const T = (ms: number) => DateTime.formatIso(DateTime.add({ milliseconds: ms })(BASE)); +const MIN = 60_000; + +describe("ThreadPostStartActivityService", () => { + it("advances last provider activity on meaningful progress only", () => { + const service = make(); + service.recordActivity("thread-1", T(0), { + kind: "tool.started", + payload: { toolCallId: "a", title: "Bash" }, + }); + service.recordActivity("thread-1", T(MIN), { + kind: "task.progress", + payload: { taskId: "t1", usageSnapshot: true, typedUsage: { inputTokens: 1 } }, + }); + service.recordContentProgress("thread-1", T(2 * MIN)); + + const state = service.getThreadPostStartActivity("thread-1"); + expect(state?.lastProviderActivityAt).toBe(T(2 * MIN)); + }); + + it("correlates tool progress by toolUseId and completes on terminal updates", () => { + const service = make(); + service.recordActivity("thread-1", T(0), { + kind: "tool.started", + payload: { toolUseId: "claude-1", title: "Bash" }, + }); + service.recordActivity("thread-1", T(4 * MIN), { + kind: "tool.progress", + payload: { toolUseId: "claude-1", toolName: "Bash", elapsedSeconds: 240 }, + }); + expect(service.getThreadPostStartActivity("thread-1")?.outstandingTools).toHaveLength(1); + expect( + service.getThreadPostStartActivity("thread-1")?.outstandingTools[0]?.lastObservedAt, + ).toBe(T(4 * MIN)); + + service.recordActivity("thread-1", T(5 * MIN), { + kind: "tool.updated", + payload: { toolUseId: "claude-1", status: "completed" }, + }); + const completed = service.getThreadPostStartActivity("thread-1"); + expect(completed?.outstandingTools).toHaveLength(0); + expect(completed?.lastToolCompletedAt).toBe(T(5 * MIN)); + }); + + it("keeps overlapping tools independent and clears on session end", () => { + const service = make(); + service.recordActivity("thread-1", T(0), { + kind: "tool.started", + payload: { toolCallId: "a", title: "A" }, + }); + service.recordActivity("thread-1", T(MIN), { + kind: "tool.started", + payload: { toolCallId: "b", title: "B" }, + }); + service.recordActivity("thread-1", T(2 * MIN), { + kind: "tool.completed", + payload: { toolCallId: "a" }, + }); + expect( + service.getThreadPostStartActivity("thread-1")?.outstandingTools.map((t) => t.toolCallId), + ).toEqual(["b"]); + + service.clearThread("thread-1"); + expect(service.getThreadPostStartActivity("thread-1")).toBeNull(); + }); + + it("reports null for a thread it has never observed", () => { + const service = make(); + expect(service.getThreadPostStartActivity("thread-none")).toBeNull(); + }); + + it("resets on a superseding turn and ignores the old turn's traffic", () => { + const service = make(); + service.beginTurn("thread-1", "turn-a"); + service.recordActivity( + "thread-1", + T(0), + { kind: "tool.started", payload: { toolCallId: "a", title: "A" } }, + "turn-a", + ); + service.recordContentProgress("thread-1", T(MIN), "turn-a"); + expect(service.getThreadPostStartActivity("thread-1")?.outstandingTools).toHaveLength(1); + + service.beginTurn("thread-1", "turn-b"); + const fresh = service.getThreadPostStartActivity("thread-1"); + expect(fresh?.outstandingTools).toEqual([]); + expect(fresh?.lastProviderActivityAt).toBeNull(); + + // Late A content and tool traffic must not refresh B. + service.recordContentProgress("thread-1", T(2 * MIN), "turn-a"); + service.recordActivity( + "thread-1", + T(2 * MIN), + { kind: "tool.started", payload: { toolCallId: "a", title: "A" } }, + "turn-a", + ); + const stale = service.getThreadPostStartActivity("thread-1"); + expect(stale?.lastProviderActivityAt).toBeNull(); + expect(stale?.outstandingTools).toEqual([]); + + // B's own traffic is observed. + service.recordContentProgress("thread-1", T(3 * MIN), "turn-b"); + expect(service.getThreadPostStartActivity("thread-1")?.lastProviderActivityAt).toBe(T(3 * MIN)); + }); + + it("does not reopen a completed tool on a later update", () => { + const service = make(); + service.beginTurn("thread-1", "turn-a"); + service.recordActivity( + "thread-1", + T(0), + { kind: "tool.started", payload: { toolCallId: "a", title: "A" } }, + "turn-a", + ); + service.recordActivity( + "thread-1", + T(2 * MIN), + { kind: "tool.completed", payload: { toolCallId: "a" } }, + "turn-a", + ); + service.recordActivity( + "thread-1", + T(3 * MIN), + { kind: "tool.progress", payload: { toolCallId: "a", title: "A" } }, + "turn-a", + ); + const state = service.getThreadPostStartActivity("thread-1"); + expect(state?.outstandingTools).toEqual([]); + expect(state?.lastToolCompletedAt).toBe(T(2 * MIN)); + }); + + it("exposes completed tool ids for client-side reconciliation", () => { + const service = make(); + service.beginTurn("thread-1", "turn-a"); + service.recordActivity( + "thread-1", + T(0), + { kind: "tool.started", payload: { toolUseId: "claude-1", title: "Bash" } }, + "turn-a", + ); + service.recordActivity( + "thread-1", + T(MIN), + { kind: "tool.updated", payload: { toolUseId: "claude-1", status: "completed" } }, + "turn-a", + ); + expect(service.getThreadPostStartActivity("thread-1")?.completedToolIds).toEqual(["claude-1"]); + }); + + it("treats beginTurn for the current turn as a no-op", () => { + const service = make(); + service.beginTurn("thread-1", "turn-a"); + service.recordContentProgress("thread-1", T(MIN), "turn-a"); + service.beginTurn("thread-1", "turn-a"); + expect(service.getThreadPostStartActivity("thread-1")?.lastProviderActivityAt).toBe(T(MIN)); + }); +}); diff --git a/apps/server/src/orchestration/ThreadPostStartActivity.ts b/apps/server/src/orchestration/ThreadPostStartActivity.ts new file mode 100644 index 000000000000..2a7d87842430 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPostStartActivity.ts @@ -0,0 +1,301 @@ +/** + * ThreadPostStartActivityService - in-memory per-thread provider observation + * for the post-start visibility surfaces (inline status and the environment + * notification coordinator). + * + * Provider event timestamps are not trustworthy progress signals: OpenCode + * stamps every delta of a part with the part's start time, and running-tool + * timestamps can stay pinned to the tool start. Ingestion records each + * meaningful provider observation on the server clock here, and the shell + * query reads it at mapping time — no persistence, no migration (same pattern + * as ThreadBackgroundLivenessService / ThreadPlanProgressService). + * + * Observations are scoped to the thread's current turn. A superseding turn + * resets the record, and traffic carrying a superseded turn id is ignored, so + * late events cannot refresh a newer turn or clear its evidence. Tools that + * complete are remembered as completed, so a later late update cannot reopen + * them. + * + * Because these observations are live-only, a server restart (or any replay + * of stored events) cannot manufacture resumed progress: the registry is empty + * until new provider events arrive, and the client falls back to the persisted + * turn origin. Cleared when a turn ends or the session dies. + * + * @module ThreadPostStartActivityService + */ +import { + isMeaningfulProviderActivity, + type PostStartOutstandingTool, +} from "@t3tools/shared/postStartActivity"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export type ThreadPostStartActivity = { + /** Turn this observation describes; null while the accepted request is pending. */ + readonly turnId: string | null; + readonly lastProviderActivityAt: string | null; + readonly lastToolCompletedAt: string | null; + readonly outstandingTools: ReadonlyArray; + readonly completedToolIds: ReadonlyArray; +}; + +const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "declined", + "stopped", +]); + +interface ThreadObservationState { + /** The turn this record describes; null while a request is pending. */ + turnId: string | null; + /** + * Message id of the accepted request whose provider turn is not yet named. + * While set, named provider traffic is rejected: a late event carrying the + * previous turn's id must not recreate live evidence the new request would + * consume. + */ + pendingRequestId: string | null; + lastProviderActivityAt: string | null; + lastToolCompletedAt: string | null; + readonly tools: Map; + readonly completedToolIds: Set; +} + +function parseMs(value: string | null): number | null { + if (value === null) return null; + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; +} + +function maxTimestamp(left: string | null, right: string | null): string | null { + const leftMs = parseMs(left); + const rightMs = parseMs(right); + if (leftMs === null && rightMs === null) return null; + if (leftMs === null) return right; + if (rightMs === null) return left; + return leftMs >= rightMs ? left : right; +} + +function record(payload: unknown): Record | null { + return payload !== null && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; +} + +function trimmed(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const next = value.trim(); + return next.length > 0 ? next : undefined; +} + +export class ThreadPostStartActivityService extends Context.Service< + ThreadPostStartActivityService, + { + /** + * Start (or continue) observing a turn. A different turn id supersedes the + * previous record: outstanding tools and completion memory reset so the new + * turn never inherits the old turn's evidence. + */ + readonly beginTurn: (threadId: string, turnId: string | null) => void; + + /** + * The user's request has been accepted but the provider turn is not named + * yet (session `starting`, `activeTurnId` null). Reset the record to the + * pending request so late traffic from the previously ended turn cannot + * recreate live evidence the new request would consume. + */ + readonly beginPendingRequest: (threadId: string, requestId: string) => void; + + /** + * Record one persisted activity row's normalized event. `observedAt` is the + * server clock instant the event was observed, not the provider timestamp. + * `turnId` scopes the observation to the current turn; events naming a + * superseded turn are ignored. + */ + readonly recordActivity: ( + threadId: string, + observedAt: string, + activity: Pick, + turnId?: string | null, + ) => void; + + /** Assistant/reasoning text progress, which has no activity row of its own. */ + readonly recordContentProgress: ( + threadId: string, + observedAt: string, + turnId?: string | null, + ) => void; + + /** + * Turn ended or session died: the observation no longer describes live work. + * When `turnId` is supplied, a terminal event naming a different turn (a + * delayed completion from the previously ended turn) is ignored so it + * cannot erase the pending/current record this visibility surface owns. + * This only affects observation ownership, never execution lifecycle. + */ + readonly clearThread: (threadId: string, turnId?: string | null) => void; + + readonly getThreadPostStartActivity: (threadId: string) => ThreadPostStartActivity | null; + } +>()("t3/orchestration/ThreadPostStartActivity/ThreadPostStartActivityService") {} + +export function make(): ThreadPostStartActivityService["Service"] { + const stateByThreadId = new Map(); + + const emptyState = ( + turnId: string | null, + pendingRequestId: string | null, + ): ThreadObservationState => ({ + turnId, + pendingRequestId, + lastProviderActivityAt: null, + lastToolCompletedAt: null, + tools: new Map(), + completedToolIds: new Set(), + }); + + /** + * Resolve the state for an observation, or null when the event belongs to a + * turn that has been superseded. Events with no turn id are accepted as the + * current (only) turn so providers that omit turn identity still observe. + * While a request is pending and unnamed, any named event belongs to some + * other (likely previous) turn and is rejected: it must not seed the new + * request's evidence. + */ + const stateForObservation = ( + threadId: string, + turnId: string | null | undefined, + ): ThreadObservationState | null => { + const existing = stateByThreadId.get(threadId); + if (existing === undefined) { + return stateByThreadId.set(threadId, emptyState(turnId ?? null, null)).get(threadId)!; + } + if (existing.pendingRequestId !== null && existing.turnId === null) { + // Pending ownership: only unnamed traffic is this request's. + return turnId === undefined || turnId === null ? existing : null; + } + if (turnId === undefined || turnId === null) return existing; + if (existing.turnId === null) { + existing.turnId = turnId; + return existing; + } + if (existing.turnId !== turnId) return null; + return existing; + }; + + return { + beginTurn: (threadId, turnId) => { + const existing = stateByThreadId.get(threadId); + if ( + existing !== undefined && + existing.turnId === turnId && + existing.pendingRequestId === null + ) { + return; + } + stateByThreadId.set(threadId, emptyState(turnId, null)); + }, + + beginPendingRequest: (threadId, requestId) => { + const existing = stateByThreadId.get(threadId); + if ( + existing !== undefined && + existing.turnId === null && + existing.pendingRequestId === requestId + ) { + return; + } + stateByThreadId.set(threadId, emptyState(null, requestId)); + }, + + recordActivity: (threadId, observedAt, activity, turnId) => { + if (!isMeaningfulProviderActivity({ kind: activity.kind, payload: activity.payload })) { + return; + } + const state = stateForObservation(threadId, turnId); + if (state === null) return; + state.lastProviderActivityAt = maxTimestamp(state.lastProviderActivityAt, observedAt); + + const payload = record(activity.payload); + const key = trimmed(payload?.toolCallId) ?? trimmed(payload?.toolUseId); + if (key === undefined) return; + + if (activity.kind === "tool.started") { + if (state.completedToolIds.has(key)) return; + state.tools.set(key, { + toolCallId: key, + title: trimmed(payload?.title) ?? trimmed(payload?.toolName) ?? "Tool", + itemType: trimmed(payload?.itemType) ?? null, + startedAt: observedAt, + lastObservedAt: observedAt, + }); + return; + } + + if (activity.kind === "tool.completed") { + state.tools.delete(key); + state.completedToolIds.add(key); + state.lastToolCompletedAt = maxTimestamp(state.lastToolCompletedAt, observedAt); + return; + } + + if (activity.kind === "tool.updated" || activity.kind === "tool.progress") { + const status = trimmed(payload?.status); + if ( + activity.kind === "tool.updated" && + status !== undefined && + TERMINAL_TOOL_STATUSES.has(status) + ) { + state.tools.delete(key); + state.completedToolIds.add(key); + state.lastToolCompletedAt = maxTimestamp(state.lastToolCompletedAt, observedAt); + return; + } + if (state.completedToolIds.has(key)) return; + const existing = state.tools.get(key); + state.tools.set(key, { + toolCallId: key, + title: trimmed(payload?.title) ?? trimmed(payload?.toolName) ?? existing?.title ?? "Tool", + itemType: trimmed(payload?.itemType) ?? existing?.itemType ?? null, + startedAt: existing?.startedAt ?? observedAt, + lastObservedAt: observedAt, + }); + } + }, + + recordContentProgress: (threadId, observedAt, turnId) => { + const state = stateForObservation(threadId, turnId); + if (state === null) return; + state.lastProviderActivityAt = maxTimestamp(state.lastProviderActivityAt, observedAt); + }, + + clearThread: (threadId, turnId) => { + const existing = stateByThreadId.get(threadId); + if (existing === undefined) return; + if (turnId !== undefined && turnId !== null) { + // A pending request owns this record until its own turn is named; a + // terminal event from another turn must not erase it. + if (existing.pendingRequestId !== null && existing.turnId === null) return; + if (existing.turnId !== null && existing.turnId !== turnId) return; + } + stateByThreadId.delete(threadId); + }, + + getThreadPostStartActivity: (threadId) => { + const state = stateByThreadId.get(threadId); + if (!state) return null; + return { + turnId: state.turnId, + lastProviderActivityAt: state.lastProviderActivityAt, + lastToolCompletedAt: state.lastToolCompletedAt, + outstandingTools: [...state.tools.values()], + completedToolIds: [...state.completedToolIds], + }; + }, + }; +} + +export const layer = Layer.effect(ThreadPostStartActivityService, Effect.sync(make)); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index ea02e5e2ebf9..fea643be6cda 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -7,6 +7,7 @@ import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "./ThreadPostStartActivity.ts"; const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, @@ -28,6 +29,7 @@ const OrchestrationInfrastructureLayerLive = Layer.mergeAll( ).pipe( Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provideMerge(ThreadPlanProgress.layer), + Layer.provideMerge(ThreadPostStartActivity.layer), ); export const OrchestrationLayerLive = Layer.mergeAll( diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4eb03a5cc036..18af8b242d06 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -38,6 +38,7 @@ import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderComm import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import * as ThreadPostStartActivity from "../orchestration/ThreadPostStartActivity.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; @@ -572,6 +573,7 @@ const integrationLayer = Layer.mergeAll( ).pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), + Layer.provide(ThreadPostStartActivity.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ec113ab7c521..cd311efae0bf 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -19,11 +19,35 @@ import { makeMemoryConsolidationNotificationFilter, openCodexThread, readCodexThread, + readRouteFields, rollbackCodexThread, toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +describe("Codex notification routing", () => { + it("carries the command/file item id and the MCP progress item id", () => { + for (const method of [ + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/mcpToolCall/progress", + ] as const) { + const route = readRouteFields({ + method, + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "item-1", + delta: "output", + message: "working", + }, + } as unknown as Parameters[0]); + NodeAssert.equal(route.turnId, "turn-1"); + NodeAssert.equal(route.itemId, "item-1"); + } + }); +}); + describe("Codex thread history", () => { for (const numTurns of [1, 2, 3, 5]) { it.effect(`reverts ${numTurns} paginated turns at the durable boundary`, () => diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 674d23327b65..4402e7d1dd60 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -867,7 +867,7 @@ export function makeMemoryConsolidationNotificationFilter(): ( }; } -function readRouteFields(notification: CodexServerNotification): { +export function readRouteFields(notification: CodexServerNotification): { readonly turnId: TurnId | undefined; readonly itemId: ProviderItemId | undefined; } { @@ -914,6 +914,7 @@ function readRouteFields(notification: CodexServerNotification): { case "item/reasoning/summaryTextDelta": case "item/reasoning/summaryPartAdded": case "item/reasoning/textDelta": + case "item/mcpToolCall/progress": return { turnId: TurnId.make(notification.params.turnId), itemId: ProviderItemId.make(notification.params.itemId), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b97411e7a44b..ef7695eb3506 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -79,6 +79,10 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { + derivePostStartActivityAnchors, + type PostStartKnownWait, +} from "@t3tools/shared/postStartActivity"; import { getTerminalLabel, nextTerminalId, @@ -336,6 +340,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { resolvePostStartObservationReceipt } from "@t3tools/client-runtime/state/post-start-observation-receipt"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -2106,6 +2111,48 @@ export default function ChatView(props: ChatViewProps) { const activeRunningTurnId = (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); + // Post-start visibility: reduce the current turn's persisted events once per + // thread-data change; the notice row resolves this against the clock itself. + // The shell's live observation (server clock) advances on streaming text and + // tool heartbeats whose provider timestamps stay pinned to the start; a + // restarted or replayed server supplies none, so stored rows never fake + // resumed progress. + // The receipt registry pairs each distinct observation with the client + // instant it actually arrived, so cached navigation or a preference remount + // reuses the original basis instead of inventing clock skew. + const postStartActivityAnchors = useMemo(() => { + if (!activeThread) return null; + const knownWait: PostStartKnownWait | null = activeThreadShell?.hasPendingApprovals + ? "approval" + : activeThreadShell?.hasPendingUserInput + ? "input" + : null; + const live = activeThreadShell?.postStartActivity ?? null; + const observedAt = live?.observedAt ?? null; + const receipt = + observedAt === null || activeThreadEnvironmentId === null || activeThreadId === null + ? null + : resolvePostStartObservationReceipt(activeThreadEnvironmentId, activeThreadId, observedAt); + return derivePostStartActivityAnchors({ + activities: activeThread.activities ?? [], + latestTurn: activeLatestTurn, + session: activeThread.session ?? null, + knownWait, + pendingStartedAt: activeThreadShell?.latestUserMessageAt ?? null, + live, + receivedAtMs: receipt?.wallMs ?? null, + receivedMonotonicMs: receipt?.monotonicMs ?? null, + }); + }, [ + activeThread, + activeLatestTurn, + activeThreadEnvironmentId, + activeThreadId, + activeThreadShell?.hasPendingApprovals, + activeThreadShell?.hasPendingUserInput, + activeThreadShell?.latestUserMessageAt, + activeThreadShell?.postStartActivity, + ]); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that @@ -9902,6 +9949,10 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} isCompacting={!paintOnlyDisplayedTimeline && isCompacting} activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} + postStartActivityAnchors={ + paintOnlyDisplayedTimeline ? null : postStartActivityAnchors + } + postStartConnection={activeEnvironmentUnavailable ? "disconnected" : "live"} worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} onCancelWorktreeSetup={onCancelWorktreeSetup} {...(draftId ? { onWorktreeSetupWorkLocally } : {})} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx index 3ce4b8d6f176..bf59662faa00 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -99,7 +99,14 @@ beforeEach(() => { TestNotification.sent = []; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("Notification", TestNotification); - vi.stubGlobal("window", Object.assign(new EventTarget(), { focus: vi.fn() })); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + focus: vi.fn(), + setInterval: globalThis.setInterval.bind(globalThis), + clearInterval: globalThis.clearInterval.bind(globalThis), + }), + ); vi.stubGlobal( "document", Object.assign(new EventTarget(), { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 860b6389dc12..2d1f80537ae7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -17,9 +17,20 @@ const state = vi.hoisted(() => ({ approval: false, sessionError: false, turnError: false, + sessionRunning: false, + postStartActivity: null as null | { + lastProviderActivityAt: string | null; + lastToolCompletedAt: string | null; + outstandingTools: []; + observedAt?: string | null; + }, + environments: ["env-1"] as string[], + threadsByEnv: {} as Record>>, + envLive: {} as Record, + toastCounter: 0, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => - "toast-1", + `toast-${++state.toastCounter}`, ), close: vi.fn(), navigate: vi.fn(), @@ -30,26 +41,33 @@ const state = vi.hoisted(() => ({ })); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ - status: state.live ? "live" : "disconnected", - snapshot: Option.some({ - threads: [ - { - id: "thread-1", - title: "Fix the login form", - archivedAt: state.archivedAt, - hasPendingUserInput: state.input, - hasPendingApprovals: state.approval, - session: state.sessionError ? { status: "error" } : null, - latestTurn: { - turnId: "turn-1", - state: state.turnError ? "error" : state.completedAt ? "completed" : "running", - completedAt: state.completedAt, - }, + useAtomValue: (environmentId: string) => { + if (!state.live || state.envLive[environmentId] === false) { + return { status: "disconnected", snapshot: Option.none() }; + } + const threads = state.threadsByEnv[environmentId] ?? [ + { + id: "thread-1", + title: "Fix the login form", + archivedAt: state.archivedAt, + hasPendingUserInput: state.input, + hasPendingApprovals: state.approval, + session: state.sessionError + ? { status: "error" } + : state.sessionRunning + ? { status: "running", activeTurnId: "turn-1" } + : null, + postStartActivity: state.postStartActivity, + latestUserMessageAt: null, + latestTurn: { + turnId: "turn-1", + state: state.turnError ? "error" : state.completedAt ? "completed" : "running", + completedAt: state.completedAt, }, - ], - }), - }), + }, + ]; + return { status: "live", snapshot: Option.some({ threads }) }; + }, })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate, @@ -64,10 +82,12 @@ vi.mock("../hooks/useSettings", () => ({ getClientSettings: () => ({ notificationMode: state.mode }), })); vi.mock("../state/environments", () => ({ - useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }), + useEnvironments: () => ({ + environments: state.environments.map((environmentId) => ({ environmentId })), + }), })); vi.mock("../state/shell", () => ({ - environmentShell: { stateValueAtom: vi.fn() }, + environmentShell: { stateValueAtom: (environmentId: string) => environmentId }, })); vi.mock("../threadNotifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -79,6 +99,7 @@ vi.mock("./ui/toast", () => ({ })); import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; +import { resetPostStartObservationReceipts } from "@t3tools/client-runtime/state/post-start-observation-receipt"; let renderer: ReactTestRenderer | undefined; @@ -94,8 +115,39 @@ async function complete() { await render(); } +const MIN = 60_000; +function agoIso(ms: number): string { + return new Date(Date.now() - ms).toISOString(); +} +/** + * A running thread carrying a real server observation basis. `lastActivityAgoMs` + * of 0 is fresh activity (active); past five minutes is quiet. + */ +function observedThread( + overrides: Partial<{ id: string; title: string; lastActivityAgoMs: number }> = {}, +) { + const lastActivityAgoMs = overrides.lastActivityAgoMs ?? 0; + return { + id: overrides.id ?? "thread-1", + title: overrides.title ?? "Fix the login form", + archivedAt: null, + hasPendingUserInput: false, + hasPendingApprovals: false, + session: { status: "running", activeTurnId: "turn-1" }, + latestUserMessageAt: null, + latestTurn: { turnId: "turn-1", state: "running", completedAt: null }, + postStartActivity: { + lastProviderActivityAt: agoIso(lastActivityAgoMs), + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: agoIso(0), + }, + }; +} + beforeEach(() => { vi.clearAllMocks(); + resetPostStartObservationReceipts(); Object.assign(state, { mode: "off", inApp: true, @@ -109,9 +161,21 @@ beforeEach(() => { approval: false, sessionError: false, turnError: false, + sessionRunning: false, + postStartActivity: null, + environments: ["env-1"], + threadsByEnv: {}, + envLive: {}, + toastCounter: 0, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", new EventTarget()); + const stubWindow = new EventTarget() as EventTarget & { + setInterval: typeof setInterval; + clearInterval: typeof clearInterval; + }; + stubWindow.setInterval = globalThis.setInterval.bind(globalThis); + stubWindow.clearInterval = globalThis.clearInterval.bind(globalThis); + vi.stubGlobal("window", stubWindow); vi.stubGlobal("document", { get visibilityState() { return state.visible; @@ -126,6 +190,7 @@ beforeEach(() => { afterEach(async () => { await act(() => renderer?.unmount()); renderer = undefined; + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -251,4 +316,378 @@ describe("thread notifications", () => { silent: true, }); }); + + it("does not storm on the first live snapshot and warns once per later episode", async () => { + state.sessionRunning = true; + state.postStartActivity = { + lastProviderActivityAt: "2020-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + // First live snapshot: an already-quiet thread is baselined, not alerted. + await render(); + expect(state.add).not.toHaveBeenCalled(); + + // A later episode (a new quiet origin) alerts once. + state.postStartActivity = { + lastProviderActivityAt: "2021-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "No recent provider activity" }), + ); + + // Another shell update within the same episode does not re-notify. + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + + // Real resumption: the episode ends and the toast closes. + state.postStartActivity = { + lastProviderActivityAt: new Date().toISOString(), + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.close).toHaveBeenCalledWith("toast-1"); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("keeps first-load quiet suppression across repeated unchanged evaluations (C1)", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = true; + const stale = (id: string) => observedThread({ id, title: id, lastActivityAgoMs: 6 * MIN }); + // Several already-stale threads with current-shaped observations arrive in + // the first live snapshot: hydration must not alert and must keep the + // suppression, so an unchanged repeated evaluation does not alert either. + // Hold the same observation objects across evaluations: an unchanged + // server snapshot keeps the same quiet origin and episode identity. + const quietB = stale("thread-b"); + const quietC = stale("thread-c"); + state.threadsByEnv["env-1"] = [stale("thread-a"), quietB, quietC]; + await render(); + await render(); + await render(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).not.toHaveBeenCalled(); + + // Real progress on thread-a ends its episode; thread-b/thread-c stay quiet + // and already suppressed. + state.threadsByEnv["env-1"] = [ + observedThread({ id: "thread-a", title: "thread-a", lastActivityAgoMs: 0 }), + quietB, + quietC, + ]; + await render(); + expect(state.add).not.toHaveBeenCalled(); + + // A genuinely new silence episode on thread-a alerts exactly once. + state.threadsByEnv["env-1"] = [ + observedThread({ id: "thread-a", title: "thread-a", lastActivityAgoMs: 7 * MIN }), + quietB, + quietC, + ]; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "No recent provider activity", description: "thread-a" }), + ); + }); + + it("does not replay an episode across a preference remount", async () => { + state.sessionRunning = true; + state.postStartActivity = { + lastProviderActivityAt: "2020-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + state.postStartActivity = { + lastProviderActivityAt: "2021-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + + // Both preferences off unmounts the environment list and closes the toast. + state.inApp = false; + await render(); + expect(state.close).toHaveBeenCalledWith("toast-1"); + + // Re-enabling does not replay the same episode. + state.inApp = true; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("does not warn about a silent thread the user is viewing", async () => { + state.sessionRunning = true; + state.active.threadId = "thread-1"; + state.postStartActivity = { + lastProviderActivityAt: "2020-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + state.postStartActivity = { + lastProviderActivityAt: "2021-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("raises a desktop alert for silence when away from T3", async () => { + state.sessionRunning = true; + state.mode = "notifications"; + state.focused = false; + state.postStartActivity = { + lastProviderActivityAt: "2020-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + state.postStartActivity = { + lastProviderActivityAt: "2021-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).toHaveBeenCalledWith("No recent provider activity", { + body: "Fix the login form", + tag: "env-1:thread-1:silence", + silent: true, + }); + }); + + it("plays the input sound for a silence alert when sound is enabled", async () => { + state.sessionRunning = true; + state.mode = "notifications-and-sound"; + state.postStartActivity = { + lastProviderActivityAt: "2020-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + state.postStartActivity = { + lastProviderActivityAt: "2021-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("baselines on the first live observation then notifies on the real quiet transition", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = true; + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 0 })]; + + // First live sighting while active: establish the baseline without alerting. + await render(); + expect(state.add).not.toHaveBeenCalled(); + + // The first genuine active→quiet transition notifies once. + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 6 * MIN })]; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "No recent provider activity" }), + ); + + // Staying quiet does not repeat. + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("keeps each environment's warnings and episode memory isolated", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = true; + state.environments = ["env-1", "env-2"]; + const a = (lastActivityAgoMs: number) => + observedThread({ id: "thread-a", title: "Thread A", lastActivityAgoMs }); + const b = (lastActivityAgoMs: number) => + observedThread({ id: "thread-b", title: "Thread B", lastActivityAgoMs }); + state.threadsByEnv["env-1"] = [a(0)]; + state.threadsByEnv["env-2"] = [b(0)]; + await render(); // baseline both envs, no alerts + + state.threadsByEnv["env-1"] = [a(6 * MIN)]; + state.threadsByEnv["env-2"] = [b(6 * MIN)]; + await render(); // both quiet → one alert each + expect(state.add).toHaveBeenCalledTimes(2); + const idFor = (description: string) => { + const index = state.add.mock.calls.findIndex((call) => call[0]?.description === description); + return state.add.mock.results[index]?.value; + }; + const toastA = idFor("Thread A"); + const toastB = idFor("Thread B"); + expect(toastA).toBeDefined(); + expect(toastB).toBeDefined(); + + // env-2 resumes: only its own warning closes. + state.threadsByEnv["env-2"] = [b(0)]; + await render(); + expect(state.close).toHaveBeenCalledWith(toastB); + expect(state.close).not.toHaveBeenCalledWith(toastA); + + // env-2 quiet again on a new origin notifies again; env-1's memory is intact + // and does not replay. + state.threadsByEnv["env-2"] = [b(7 * MIN)]; + await render(); + expect(state.add).toHaveBeenCalledTimes(3); + expect(state.add.mock.calls[2]?.[0]?.description).toBe("Thread B"); + }); + + it("delivers sound-only once per episode rather than on every tick", async () => { + state.mode = "sound"; + state.inApp = false; + state.focused = true; + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 0 })]; + await render(); + expect(state.sound).not.toHaveBeenCalled(); + + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 6 * MIN })]; + await render(); + expect(state.sound).toHaveBeenCalledTimes(1); + + // Repeated shell/clock ticks within the same episode stay silent. + await render(); + await render(); + expect(state.sound).toHaveBeenCalledTimes(1); + + // A new episode (new quiet origin) delivers again. + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 7 * MIN })]; + await render(); + expect(state.sound).toHaveBeenCalledTimes(2); + }); + + it("closes the matching desktop silence notification on resumption, leaving others open", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = false; + state.environments = ["env-1", "env-2"]; + const a = (lastActivityAgoMs: number) => + observedThread({ id: "thread-a", title: "Thread A", lastActivityAgoMs }); + const b = (lastActivityAgoMs: number) => + observedThread({ id: "thread-b", title: "Thread B", lastActivityAgoMs }); + state.threadsByEnv["env-1"] = [a(0)]; + state.threadsByEnv["env-2"] = [b(0)]; + await render(); + + state.threadsByEnv["env-1"] = [a(6 * MIN)]; + state.threadsByEnv["env-2"] = [b(6 * MIN)]; + await render(); + const sent = state.notification.mock.results.map( + (result) => result.value as { tag: string; close: ReturnType }, + ); + const silenceA = sent.find((notification) => notification.tag === "env-1:thread-a:silence"); + const silenceB = sent.find((notification) => notification.tag === "env-2:thread-b:silence"); + expect(silenceA).toBeDefined(); + expect(silenceB).toBeDefined(); + + // env-1 resumes: its desktop warning closes; env-2's stays. + state.threadsByEnv["env-1"] = [a(0)]; + await render(); + expect(silenceA!.close).toHaveBeenCalled(); + expect(silenceB!.close).not.toHaveBeenCalled(); + }); + + it("treats a selected thread in a hidden window as away from T3", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = false; + state.visible = "hidden"; + state.active = { environmentId: "env-1", threadId: "thread-1" }; + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 0 })]; + await render(); + + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 6 * MIN })]; + await render(); + // Not actively viewed, so the away-from-T3 desktop preference applies + // instead of suppressing the warning. + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledWith( + "No recent provider activity", + expect.objectContaining({ tag: "env-1:thread-1:silence" }), + ); + }); + + it("closes the desktop silence notification when the turn reaches terminal state", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = false; + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 0 })]; + await render(); + + state.threadsByEnv["env-1"] = [observedThread({ lastActivityAgoMs: 6 * MIN })]; + await render(); + const sent = state.notification.mock.results.map( + (result) => result.value as { tag: string; close: ReturnType }, + ); + const silence = sent.find((notification) => notification.tag === "env-1:thread-1:silence"); + expect(silence).toBeDefined(); + + // The turn finishes rather than resuming: the warning is closed. + state.threadsByEnv["env-1"] = [ + { + ...observedThread({ lastActivityAgoMs: 6 * MIN }), + session: { status: "ready", activeTurnId: null }, + latestTurn: { turnId: "turn-1", state: "completed", completedAt: agoIso(0) }, + }, + ]; + await render(); + expect(silence!.close).toHaveBeenCalled(); + }); + + it("closes only the disconnected environment's live warnings", async () => { + state.mode = "notifications"; + state.inApp = true; + state.focused = true; + state.environments = ["env-1", "env-2"]; + const a = observedThread({ id: "thread-a", title: "Thread A", lastActivityAgoMs: 0 }); + const b = observedThread({ id: "thread-b", title: "Thread B", lastActivityAgoMs: 0 }); + state.threadsByEnv["env-1"] = [a]; + state.threadsByEnv["env-2"] = [b]; + await render(); + + const quiet = (thread: typeof a) => ({ + ...thread, + postStartActivity: { + ...thread.postStartActivity, + lastProviderActivityAt: agoIso(6 * MIN), + }, + }); + state.threadsByEnv["env-1"] = [quiet(a)]; + state.threadsByEnv["env-2"] = [quiet(b)]; + await render(); + expect(state.add).toHaveBeenCalledTimes(2); + const toastA = state.add.mock.calls[0]?.[0]; + const toastB = state.add.mock.calls[1]?.[0]; + const idFor = (description: string) => { + const index = state.add.mock.calls.findIndex((call) => call[0]?.description === description); + return state.add.mock.results[index]?.value; + }; + expect(toastA?.description).toBe("Thread A"); + expect(toastB?.description).toBe("Thread B"); + const idA = idFor("Thread A"); + const idB = idFor("Thread B"); + + // env-2 disconnects: its warning closes; env-1's live warning stays. + state.envLive["env-2"] = false; + await render(); + expect(state.close).toHaveBeenCalledWith(idB); + expect(state.close).not.toHaveBeenCalledWith(idA); + }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 1401965e90c7..cc8006387b58 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -5,13 +5,22 @@ import * as Option from "effect/Option"; import { CircleAlertIcon, CircleCheckIcon, + ClockIcon, MessageCircleQuestionIcon, ShieldQuestionIcon, } from "lucide-react"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + derivePostStartActivityAnchors, + resolvePostStartActivity, +} from "@t3tools/shared/postStartActivity"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironments } from "../state/environments"; +import { + monotonicNowMs, + resolvePostStartObservationReceipt, +} from "@t3tools/client-runtime/state/post-start-observation-receipt"; import { environmentShell } from "../state/shell"; import { hasDesktopNotifications, @@ -32,11 +41,32 @@ export function ThreadNotificationCoordinator() { const pending = useRef( new Map(), ); + // Silence-episode memory lives on the always-mounted parent. The child list + // unmounts when both notification preferences are off; keeping this here + // means re-enabling in-app alerts cannot replay an episode the user already + // saw. Keyed by `${environmentId}:${threadId}:${episodeKey}`. + const notifiedSilenceEpisodes = useRef(new Set()); + const openSilenceToasts = useRef(new Map()); + // Desktop silence notifications by episode key, so resumption/terminal state + // can close exactly the one episode's system notification without touching + // unrelated pending notifications. + const openSilenceDesktopNotifications = useRef(new Map()); + // Threads already seen in a live snapshot. Kept on the always-mounted parent + // so a child remount caused by both preferences being off does not re-baseline + // and suppress a genuinely new episode. + const hydratedThreads = useRef(new Set()); const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => { pending.current.get(notification.tag)?.notification.close(); pending.current.set(notification.tag, { environmentId, notification }); setNotificationBadge(pending.current.size); }, []); + const dismissNotification = useCallback((tag: string) => { + const entry = pending.current.get(tag); + if (entry === undefined) return; + entry.notification.close(); + pending.current.delete(tag); + setNotificationBadge(pending.current.size); + }, []); useEffect(() => { const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); @@ -76,6 +106,23 @@ export function ThreadNotificationCoordinator() { }; }, [mode]); + useEffect( + () => () => { + for (const toastId of openSilenceToasts.current.values()) toastManager.close(toastId); + openSilenceToasts.current.clear(); + }, + [], + ); + + // When both preferences are off the child list unmounts, so its effect cannot + // close the warnings it opened. Close them here; the episode memory above + // still prevents a replay when notifications are re-enabled. + useEffect(() => { + if (mode !== "off" || inAppNotificationsEnabled) return; + for (const toastId of openSilenceToasts.current.values()) toastManager.close(toastId); + openSilenceToasts.current.clear(); + }, [inAppNotificationsEnabled, mode]); + if (mode === "off" && !inAppNotificationsEnabled) return null; return environments.map((environment) => ( @@ -83,6 +130,11 @@ export function ThreadNotificationCoordinator() { key={environment.environmentId} environmentId={environment.environmentId} onNotification={onNotification} + dismissNotification={dismissNotification} + notifiedSilenceEpisodes={notifiedSilenceEpisodes} + openSilenceToasts={openSilenceToasts} + openSilenceDesktopNotifications={openSilenceDesktopNotifications} + hydratedThreads={hydratedThreads} /> )); } @@ -90,9 +142,19 @@ export function ThreadNotificationCoordinator() { function EnvironmentNotifications({ environmentId, onNotification, + dismissNotification, + notifiedSilenceEpisodes, + openSilenceToasts, + openSilenceDesktopNotifications, + hydratedThreads, }: { environmentId: EnvironmentId; onNotification: (environmentId: EnvironmentId, notification: Notification) => void; + dismissNotification: (tag: string) => void; + notifiedSilenceEpisodes: React.RefObject>; + openSilenceToasts: React.RefObject>; + openSilenceDesktopNotifications: React.RefObject>; + hydratedThreads: React.RefObject>; }) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); @@ -106,6 +168,20 @@ function EnvironmentNotifications({ const previous = useRef( new Map(), ); + // Threads already seen in a live snapshot. The first sighting establishes the + // hydration baseline for the silence signal: an already-quiet thread the user + // did not just watch must not toast the moment notifications connect. + // Owned by the parent so a child remount does not re-baseline. + // Server-clock basis: the observation stamps the server instant; the receipt + // registry pairs each distinct observation with the client instant it + // actually landed, including its monotonic baseline, so unrelated shell + // updates and renders cannot re-date it. + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), 30_000); + return () => window.clearInterval(id); + }, []); useEffect(() => { if (shell.status !== "live" || Option.isNone(shell.snapshot)) { @@ -233,5 +309,187 @@ function EnvironmentNotifications({ shell, ]); + // Post-start silence: an unattended running thread whose provider has gone + // quiet past the conservative threshold raises one alert per episode. The + // environment shell already carries the server-observed activity, so this + // covers threads the user is not viewing and follows the same in-app, + // desktop and sound modes as the attention/completion signal above. + useEffect(() => { + const prefix = `${environmentId}:`; + const closeEnvironmentToasts = () => { + for (const [key, toastId] of [...openSilenceToasts.current]) { + if (!key.startsWith(prefix)) continue; + toastManager.close(toastId); + openSilenceToasts.current.delete(key); + } + }; + const closeEnvironmentDesktopNotifications = () => { + for (const [key, tag] of [...openSilenceDesktopNotifications.current]) { + if (!key.startsWith(prefix)) continue; + dismissNotification(tag); + openSilenceDesktopNotifications.current.delete(key); + } + }; + if (shell.status !== "live" || Option.isNone(shell.snapshot)) { + // A disconnected environment cannot be observed: end its episodes without + // touching another environment's open warnings or dedup memory. + closeEnvironmentToasts(); + closeEnvironmentDesktopNotifications(); + return; + } + // Turning in-app alerts off closes any open silence toast; the episode stays + // remembered so re-enabling cannot replay it. + if (!inAppNotificationsEnabled) { + closeEnvironmentToasts(); + } + const seen = new Set(); + for (const thread of shell.snapshot.value.threads) { + if (thread.archivedAt !== null) continue; + const live = thread.postStartActivity ?? null; + const observedAt = live?.observedAt ?? null; + // Pair each distinct server observation with the client instant it + // actually arrived. The shell state records that at acceptance; an + // unrelated shell update or render reuses the same receipt instead of + // re-dating the observation, and the monotonic baseline keeps elapsed + // time honest across browser wall-clock changes. + const receipt = + observedAt === null + ? null + : resolvePostStartObservationReceipt(environmentId, thread.id, observedAt); + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: thread.latestTurn, + session: thread.session, + knownWait: thread.hasPendingApprovals + ? "approval" + : thread.hasPendingUserInput + ? "input" + : null, + pendingStartedAt: thread.latestUserMessageAt, + live, + receivedAtMs: receipt?.wallMs ?? null, + receivedMonotonicMs: receipt?.monotonicMs ?? null, + }); + const observation = resolvePostStartActivity(anchors, nowMs, { + nowMonotonicMs: monotonicNowMs(), + }); + const baselineKey = `${environmentId}:${thread.id}`; + // Baseline on the first live observation in any state (active, waiting or + // ready), not only once it is already quiet, so the first genuine + // active→quiet transition still notifies. A thread that is already quiet + // when first seen is recorded as known so hydration cannot storm. + if (!hydratedThreads.current.has(baselineKey)) { + hydratedThreads.current.add(baselineKey); + if (observation.status === "quiet" && observation.episodeKey !== null) { + const key = `${baselineKey}:${observation.episodeKey}`; + notifiedSilenceEpisodes.current.add(key); + // Mark the hydration baseline as still-current too, so the episode + // cleanup below cannot delete the suppression in this same evaluation + // and re-alert on the next unchanged one. + seen.add(key); + } + continue; + } + if (observation.status !== "quiet" || observation.episodeKey === null) continue; + const key = `${baselineKey}:${observation.episodeKey}`; + seen.add(key); + if (notifiedSilenceEpisodes.current.has(key)) continue; + + const isForeground = document.visibilityState === "visible" && document.hasFocus(); + // A selected thread is only actively viewed while T3 is foregrounded. In a + // hidden/unfocused window the existing away-from-T3 preferences apply. + const isViewing = + activeEnvironmentId === environmentId && activeThreadId === thread.id && isForeground; + if (isViewing) { + notifiedSilenceEpisodes.current.add(key); + continue; + } + const soundEnabled = hasNotificationSound(mode); + if (soundEnabled) { + void playNotificationSound("input", () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + // Sound alone is a delivery channel too: mark the episode delivered so + // sound-only mode does not replay on every timer tick. + let alerted = soundEnabled; + if (inAppNotificationsEnabled && isForeground) { + const toastId = toastManager.add({ + type: "warning", + title: "No recent provider activity", + description: thread.title, + data: { hideCopyButton: true, leadingIcon: }, + actionProps: { + children: "Open thread", + onClick: () => { + toastManager.close(toastId); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }, + }, + }); + openSilenceToasts.current.set(key, toastId); + alerted = true; + } else if ( + hasDesktopNotifications(mode) && + !isForeground && + typeof Notification !== "undefined" && + Notification.permission === "granted" + ) { + try { + const notification = new Notification("No recent provider activity", { + body: thread.title, + tag: `${environmentId}:${thread.id}:silence`, + silent: true, + }); + onNotification(environmentId, notification); + openSilenceDesktopNotifications.current.set(key, notification.tag); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }); + alerted = true; + } catch { + // Browser exposes Notification but rejects desktop presentation. + } + } + if (alerted) notifiedSilenceEpisodes.current.add(key); + } + // End this environment's episodes that are no longer quiet (resumption, + // terminal state); another environment's warnings and dedup memory are + // untouched. + for (const key of [...notifiedSilenceEpisodes.current]) { + if (!key.startsWith(prefix) || seen.has(key)) continue; + const toastId = openSilenceToasts.current.get(key); + if (toastId !== undefined) { + toastManager.close(toastId); + openSilenceToasts.current.delete(key); + } + const tag = openSilenceDesktopNotifications.current.get(key); + if (tag !== undefined) { + dismissNotification(tag); + openSilenceDesktopNotifications.current.delete(key); + } + notifiedSilenceEpisodes.current.delete(key); + } + }, [ + activeEnvironmentId, + activeThreadId, + dismissNotification, + environmentId, + inAppNotificationsEnabled, + mode, + navigate, + nowMs, + onNotification, + shell, + ]); + return null; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..bfc975255a2d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -32,6 +32,7 @@ import { shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, type MessagesTimelineRowsProjection, + POST_START_ACTIVITY_ROW_ID, WORKTREE_SETUP_ROW_ID, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; @@ -1127,6 +1128,58 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("carries one post-start notice row only while a turn is observably active", () => { + const anchors = { + turnId: "turn-1", + active: true, + turnStartedAt: "2026-01-01T00:00:00Z", + lastProviderActivityAt: null, + lastToolCompletedAt: null, + outstandingTools: [], + outstandingTool: null, + knownWait: null, + observingServerClock: false, + observationClockOffsetMs: null, + receivedAtMs: null, + receivedMonotonicMs: null, + } as const; + const base = { + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } as const; + + const working = deriveMessagesTimelineRows({ ...base, postStartActivityAnchors: anchors }); + const noticeRow = working.find((row) => row.kind === "post-start-activity"); + expect(noticeRow?.id).toBe(POST_START_ACTIVITY_ROW_ID); + + // A settled turn clears the row instead of inheriting the old episode. + expect( + deriveMessagesTimelineRows({ + ...base, + isWorking: false, + postStartActivityAnchors: anchors, + }).some((row) => row.kind === "post-start-activity"), + ).toBe(false); + + // Background-only / terminal anchors never resurrect the warning. + expect( + deriveMessagesTimelineRows({ + ...base, + postStartActivityAnchors: { ...anchors, active: false }, + }).some((row) => row.kind === "post-start-activity"), + ).toBe(false); + + // No anchors at all (another thread is painted) leaves the row out. + expect( + deriveMessagesTimelineRows({ ...base, postStartActivityAnchors: null }).some( + (row) => row.kind === "post-start-activity", + ), + ).toBe(false); + }); + it("leads the worktree setup card with the working header", () => { const snapshot: WorktreeSetupSnapshot = { threadId: ThreadId.make("thread-setup"), @@ -3685,6 +3738,45 @@ describe("computeStableMessagesTimelineRows", () => { expect(updated.result[0]).toBe(enrichedRow); }); + it("reuses the cached post-start row only when connection is unchanged", () => { + const anchors = { + turnId: "turn-1", + active: true, + turnStartedAt: "2026-01-01T00:00:00Z", + lastProviderActivityAt: null, + lastToolCompletedAt: null, + outstandingTools: [], + outstandingTool: null, + knownWait: null, + observingServerClock: false, + observationClockOffsetMs: null, + receivedAtMs: null, + receivedMonotonicMs: null, + } as const; + const liveRow: MessagesTimelineRow = { + kind: "post-start-activity", + id: POST_START_ACTIVITY_ROW_ID, + createdAt: anchors.turnStartedAt, + anchors, + connection: "live", + }; + const initial = computeStableMessagesTimelineRows([liveRow], { + byId: new Map(), + result: [], + }); + + // Same anchors object, connection changed: row reuse must not keep the + // stale live/disconnected presentation. + const disconnected: MessagesTimelineRow = { ...liveRow, connection: "disconnected" }; + const updated = computeStableMessagesTimelineRows([disconnected], initial); + expect(updated).not.toBe(initial); + expect(updated.byId.get(POST_START_ACTIVITY_ROW_ID)).toBe(disconnected); + + // Unchanged connection still reuses the row object. + const stable = computeStableMessagesTimelineRows([liveRow], initial); + expect(stable).toBe(initial); + }); + it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { const startedAt = "2026-01-01T00:00:00Z"; const turnId = TurnId.make("turn-1"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 57ed45a89d1d..08dd6bd6af11 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -31,6 +31,10 @@ import { } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import type { QueuedComposerMessage } from "../../queuedMessageStore"; +import type { + PostStartActivityAnchors, + PostStartConnectionState, +} from "@t3tools/shared/postStartActivity"; import { type MessageId, type OrchestrationLatestTurn, @@ -313,6 +317,7 @@ export type TimelineLatestTurn = Pick< >; const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +export const POST_START_ACTIVITY_ROW_ID = "post-start-activity-row"; type ActivityEntry = Extract; @@ -420,6 +425,15 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; } + | { + kind: "post-start-activity"; + id: string; + createdAt: string | null; + /** Stable anchors; the notice resolves them against the live clock. */ + anchors: PostStartActivityAnchors; + /** Whether the environment can be observed right now. */ + connection: PostStartConnectionState; + } | { kind: "worktree-setup"; id: string; @@ -946,6 +960,10 @@ export function deriveMessagesTimelineRows(input: { expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; activeTurnStartedAt: string | null; + /** Current-turn provider activity anchors for the silence notice. */ + postStartActivityAnchors?: PostStartActivityAnchors | null | undefined; + /** Whether the environment can be observed right now. */ + postStartConnection?: PostStartConnectionState | undefined; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; /** Task ids of subagents still working, used by the active tool indicator. */ @@ -1452,6 +1470,19 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); } + // Post-start visibility: while the turn is live, carry one notice row that + // resolves "recent progress vs silence vs known wait" against the clock. It + // only renders once silence crosses the threshold, so the list does not + // churn while work is healthy. + if (input.isWorking && input.postStartActivityAnchors?.active) { + nextRows.push({ + kind: "post-start-activity", + id: POST_START_ACTIVITY_ROW_ID, + createdAt: input.postStartActivityAnchors.turnStartedAt, + anchors: input.postStartActivityAnchors, + connection: input.postStartConnection ?? "live", + }); + } const rows = attachTrailingToolGroupsToAssistant(nextRows); input.queuedMessages?.forEach((queuedMessage, index) => { rows.push({ @@ -1593,6 +1624,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === (b as typeof a).createdAt; case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; + case "post-start-activity": + // Connection-only changes must produce a new row: row reuse otherwise + // keeps the stale disconnected/reconnected presentation. + return a.anchors === (b as typeof a).anchors && a.connection === (b as typeof a).connection; case "assistant-meta": { const bm = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43adf7907dbf..b0d03fd973d5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -24,6 +24,11 @@ import { } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import type { + PostStartActivityAnchors, + PostStartConnectionState, +} from "@t3tools/shared/postStartActivity"; +import { PostStartActivityNotice } from "./PostStartActivityNotice"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { resolveWorkEntryToolPresentation, @@ -411,6 +416,10 @@ interface MessagesTimelineProps { isPreparingWorktree?: boolean; isCompacting?: boolean; activeTurnStartedAt: string | null; + /** Current-turn provider activity anchors for the post-start silence notice. */ + postStartActivityAnchors?: PostStartActivityAnchors | null; + /** Whether the active environment can currently be observed. */ + postStartConnection?: PostStartConnectionState; /** Live bootstrap progress for this thread, or null when none is tracked. */ worktreeSetup?: WorktreeSetupSnapshot | null; onCancelWorktreeSetup?: () => void; @@ -488,6 +497,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isPreparingWorktree = false, isCompacting = false, activeTurnStartedAt, + postStartActivityAnchors = null, + postStartConnection = "live", agentPanelModel, onOpenAgents = NOOP_OPEN_AGENTS, listRef, @@ -782,6 +793,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds: paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, + postStartActivityAnchors, + postStartConnection, turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, @@ -805,6 +818,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, + postStartActivityAnchors, + postStartConnection, turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, @@ -1724,6 +1739,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} + {row.kind === "post-start-activity" ? ( + + ) : null} {row.kind === "worktree-setup" ? : null} {row.kind === "queued-message" ? : null} diff --git a/apps/web/src/components/chat/PostStartActivityNotice.test.tsx b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx new file mode 100644 index 000000000000..7d2479da73f8 --- /dev/null +++ b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx @@ -0,0 +1,143 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { PostStartActivityAnchors } from "@t3tools/shared/postStartActivity"; + +import { PostStartActivityNotice } from "./PostStartActivityNotice"; + +const T0 = "2026-01-01T00:00:00.000Z"; +const T0_MS = Date.parse(T0); +const THRESHOLD_MS = 5 * 60_000; + +function anchors(overrides: Partial = {}): PostStartActivityAnchors { + return { + turnId: "turn-1", + active: true, + turnStartedAt: T0, + lastProviderActivityAt: T0, + lastToolCompletedAt: null, + outstandingTools: [], + outstandingTool: null, + knownWait: null, + observingServerClock: false, + observationClockOffsetMs: null, + receivedAtMs: null, + receivedMonotonicMs: null, + ...overrides, + }; +} + +function renderedText(renderer: ReactTestRenderer): string { + const parts: string[] = []; + const walk = (node: unknown): void => { + if (typeof node === "string") { + parts.push(node); + return; + } + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (node !== null && typeof node === "object" && "children" in node) { + walk((node as { children: unknown }).children); + } + }; + walk(renderer.toJSON()); + return parts.join(" "); +} + +describe("PostStartActivityNotice", () => { + const renderers: ReactTestRenderer[] = []; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + }); + }); + afterEach(() => { + for (const renderer of renderers.splice(0)) { + act(() => renderer.unmount()); + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function render(element: Parameters[0]): ReactTestRenderer { + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(element); + }); + renderers.push(renderer); + return renderer; + } + + it("stays informative while active, then warns exactly at the threshold", () => { + vi.setSystemTime(T0_MS + THRESHOLD_MS - 1_000); + const renderer = render(); + expect(renderedText(renderer)).toContain("Provider active"); + expect(renderedText(renderer)).not.toContain("No provider activity observed"); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(renderedText(renderer)).toContain( + "No provider activity observed for over 5 minutes; this turn may still be working.", + ); + }); + + it("names an outstanding tool while active and when quiet", () => { + const tool = { + toolCallId: "call-1", + title: "npm test", + itemType: "command_execution", + startedAt: T0, + lastObservedAt: T0, + }; + vi.setSystemTime(T0_MS + 2 * 60_000); + const renderer = render( + , + ); + expect(renderedText(renderer)).toContain("Working: npm test"); + + act(() => { + vi.setSystemTime(T0_MS + 8 * 60_000); + vi.advanceTimersByTime(1_000); + }); + expect(renderedText(renderer)).toContain("No activity from npm test"); + }); + + it("shows a pending decision as an explained wait, not silence", () => { + vi.setSystemTime(T0_MS + 30 * 60_000); + const renderer = render( + , + ); + expect(renderedText(renderer)).toContain("Waiting for your approval"); + }); + + it("shows uncertainty instead of a stop when disconnected", () => { + vi.setSystemTime(T0_MS + 30 * 60_000); + const renderer = render( + , + ); + expect(renderedText(renderer)).toContain("its state is unknown"); + }); + + it("does not claim no activity when a timestamp is known but its age is not", () => { + vi.setSystemTime(T0_MS + 10 * 60_000); + const renderer = render( + , + ); + const text = renderedText(renderer); + expect(text).toContain("provider activity observed; age unknown"); + expect(text).not.toContain("no provider activity observed yet"); + }); +}); diff --git a/apps/web/src/components/chat/PostStartActivityNotice.tsx b/apps/web/src/components/chat/PostStartActivityNotice.tsx new file mode 100644 index 000000000000..53079d4ca35f --- /dev/null +++ b/apps/web/src/components/chat/PostStartActivityNotice.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react"; +import { ClockIcon, LoaderCircleIcon } from "lucide-react"; +import type { + PostStartActivityAnchors, + PostStartConnectionState, +} from "@t3tools/shared/postStartActivity"; +import { + POST_START_SILENCE_THRESHOLD_MS, + resolvePostStartActivity, +} from "@t3tools/shared/postStartActivity"; + +import { formatDuration } from "../../session-logic"; +import { monotonicNowMs } from "@t3tools/client-runtime/state/post-start-observation-receipt"; + +function formatThresholdLabel(thresholdMs: number): string { + const minutes = Math.round(thresholdMs / 60_000); + if (minutes < 1) return `${Math.round(thresholdMs / 1000)} seconds`; + return `${minutes} minute${minutes === 1 ? "" : "s"}`; +} + +function relativeAge(ageMs: number | null): string { + return ageMs === null ? "unknown" : `${formatDuration(ageMs)} ago`; +} + +/** + * Compact inline post-start status for a running turn. + * + * Unlike the warning-only first slice, this is always informative while the + * turn is active: it names the outstanding tool (with its own age), the last + * provider activity, the last real tool completion, or the pending decision + * being waited on. Once the provider passes the threshold without progress it + * switches to the qualified silence warning. It never says the turn failed and + * never changes turn state. + * + * The component self-ticks once a second so the text stays current and the + * warning appears exactly when the threshold is crossed without re-rendering + * the surrounding list. Notifications are owned by the environment-scoped + * ThreadNotificationCoordinator, not here. + */ +export function PostStartActivityNotice({ + anchors, + connection, +}: { + anchors: PostStartActivityAnchors; + connection: PostStartConnectionState; +}) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), 1_000); + return () => window.clearInterval(id); + }, []); + + const observation = resolvePostStartActivity(anchors, nowMs, { + connection, + nowMonotonicMs: monotonicNowMs(), + }); + const { status } = observation; + + if (status === "inactive") { + return null; + } + + const lastActivityDetail = + observation.lastProviderActivityAt === null + ? "no provider activity observed yet" + : observation.lastProviderActivityAgeMs === null + ? "provider activity observed; age unknown" + : `last provider activity ${relativeAge(observation.lastProviderActivityAgeMs)}`; + const completionDetail = + observation.lastToolCompletedAt === null + ? "no tool completion observed" + : observation.lastToolCompletedAgeMs === null + ? "tool completion observed; age unknown" + : `last tool completed ${relativeAge(observation.lastToolCompletedAgeMs)}`; + const toolDetail = + observation.outstandingTool === null + ? null + : observation.outstandingToolAgeMs === null + ? `${observation.outstandingTool.title} observed; age unknown` + : `${observation.outstandingTool.title} observed ${relativeAge(observation.outstandingToolAgeMs)}`; + + const mainLabel = (() => { + switch (status) { + case "unknown": + return "Can't observe this turn's provider right now; its state is unknown."; + case "waiting": + return anchors.knownWait === "approval" + ? "Waiting for your approval. This turn is paused, not silent." + : "Waiting for your input. This turn is paused, not silent."; + case "quiet": + return observation.outstandingTool !== null + ? `No activity from ${observation.outstandingTool.title} for over ${formatThresholdLabel( + POST_START_SILENCE_THRESHOLD_MS, + )}; this turn may still be working.` + : `No provider activity observed for over ${formatThresholdLabel( + POST_START_SILENCE_THRESHOLD_MS, + )}; this turn may still be working.`; + case "active": + default: + return observation.outstandingTool !== null + ? `Working: ${observation.outstandingTool.title}${ + toolDetail === null ? "" : ` · ${toolDetail}` + }` + : "Provider active."; + } + })(); + + const isWarning = status === "quiet" || status === "unknown"; + + return ( +
+
+ {isWarning ? ( + + ) : ( + + )} +
+ {mainLabel} +
+ {lastActivityDetail} · {completionDetail} + {status !== "active" && toolDetail !== null ? ` · ${toolDetail}` : ""} +
+
+
+
+ ); +} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7da6567dbbed..704f00bfa8a2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -482,6 +482,9 @@ export function deriveWorkLogEntries( if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; + // Delivery-only nudge for the post-start observation; carries no user + // content and must never render as a work-log row. + if (activity.kind === "post-start-observation") continue; if (activity.kind === "context-window.updated") continue; if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 63dfc4ab344c..ed024846605f 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -207,6 +207,10 @@ "types": "./src/state/shell.ts", "default": "./src/state/shell.ts" }, + "./state/post-start-observation-receipt": { + "types": "./src/state/postStartObservationReceipt.ts", + "default": "./src/state/postStartObservationReceipt.ts" + }, "./state/source-control": { "types": "./src/state/sourceControl.ts", "default": "./src/state/sourceControl.ts" diff --git a/packages/client-runtime/src/state/postStartObservationReceipt.test.ts b/packages/client-runtime/src/state/postStartObservationReceipt.test.ts new file mode 100644 index 000000000000..27d12be7e3c3 --- /dev/null +++ b/packages/client-runtime/src/state/postStartObservationReceipt.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + monotonicNowMs, + recordPostStartObservationReceipt, + recordSnapshotObservationReceipts, + resetPostStartObservationReceipts, + resolvePostStartObservationReceipt, +} from "./postStartObservationReceipt.ts"; + +const ENV = "env-1"; + +beforeEach(() => { + resetPostStartObservationReceipts(); +}); + +describe("postStartObservationReceipt", () => { + it("records the basis when the state accepts the bytes, not when a view reads them", () => { + recordSnapshotObservationReceipts( + ENV, + [{ id: "thread-a", postStartActivity: { observedAt: "observed-1" } }], + { wallMs: 1_000, monotonicMs: 10 }, + ); + // A later read (cached navigation, a preference remount, a render) reuses + // the original receipt. + const later = resolvePostStartObservationReceipt(ENV, "thread-a", "observed-1"); + expect(later).toEqual({ wallMs: 1_000, monotonicMs: 10 }); + }); + + it("replaces the basis only for a genuinely new observation of that thread", () => { + recordSnapshotObservationReceipts( + ENV, + [{ id: "thread-a", postStartActivity: { observedAt: "observed-1" } }], + { wallMs: 1_000, monotonicMs: 10 }, + ); + recordSnapshotObservationReceipts( + ENV, + [{ id: "thread-a", postStartActivity: { observedAt: "observed-2" } }], + { wallMs: 2_000, monotonicMs: 20 }, + ); + expect(resolvePostStartObservationReceipt(ENV, "thread-a", "observed-1")).toBeNull(); + expect(resolvePostStartObservationReceipt(ENV, "thread-a", "observed-2")).toEqual({ + wallMs: 2_000, + monotonicMs: 20, + }); + }); + + it("keeps a current observation's basis while other threads churn past the old limit", () => { + recordPostStartObservationReceipt(ENV, "thread-a", "a-quiet", { + wallMs: 1_000, + monotonicMs: 10, + }); + // More than the old 512-entry FIFO's worth of distinct observations from + // other threads, with the still-current observation present in every + // accepted snapshot. A current observation must not be evicted or re-dated. + for (let index = 0; index < 600; index += 1) { + recordSnapshotObservationReceipts( + ENV, + [ + { id: "thread-a", postStartActivity: { observedAt: "a-quiet" } }, + { id: `thread-b-${index}`, postStartActivity: { observedAt: `b-${index}` } }, + ], + { wallMs: 2_000 + index, monotonicMs: 20 + index }, + ); + } + expect(resolvePostStartObservationReceipt(ENV, "thread-a", "a-quiet")).toEqual({ + wallMs: 1_000, + monotonicMs: 10, + }); + }); + + it("does not retain a growing history of every sampled timestamp", () => { + for (let index = 0; index < 100; index += 1) { + recordPostStartObservationReceipt(ENV, "thread-a", `sample-${index}`, { + wallMs: index, + monotonicMs: index, + }); + } + // Only the latest observation for the thread is retained; earlier samples + // are gone rather than accumulating. + expect(resolvePostStartObservationReceipt(ENV, "thread-a", "sample-98")).toBeNull(); + expect(resolvePostStartObservationReceipt(ENV, "thread-a", "sample-99")).not.toBeNull(); + }); + + it("scopes the basis by environment and thread", () => { + recordSnapshotObservationReceipts( + "env-1", + [{ id: "thread-a", postStartActivity: { observedAt: "observed-1" } }], + { wallMs: 1_000, monotonicMs: 10 }, + ); + expect(resolvePostStartObservationReceipt("env-2", "thread-a", "observed-1")).toBeNull(); + expect(resolvePostStartObservationReceipt("env-1", "thread-b", "observed-1")).toBeNull(); + }); + + it("exposes a monotonic clock reading", () => { + expect(Number.isFinite(monotonicNowMs())).toBe(true); + }); +}); diff --git a/packages/client-runtime/src/state/postStartObservationReceipt.ts b/packages/client-runtime/src/state/postStartObservationReceipt.ts new file mode 100644 index 000000000000..378445cbce03 --- /dev/null +++ b/packages/client-runtime/src/state/postStartObservationReceipt.ts @@ -0,0 +1,147 @@ +/** + * Client receipt basis for live post-start observations. + * + * The environment shell carries each thread's `postStartActivity` stamped with + * a server instant (`observedAt`). Ages are meaningful only when paired with + * the client instant that observation actually reached this client: a render, + * a navigation, a preference remount, a reconnect or an unrelated shell update + * is not a new receipt and must not re-date it. + * + * Receipts are recorded by the client state itself, at the moment a shell + * snapshot is accepted (`recordSnapshotObservationReceipts`), not when a view + * first consumes them. That makes the basis independent of whether notification + * preferences are on, whether the thread is open, and which components have + * mounted. Each environment/thread keeps only its *current* observation's + * receipt, so unrelated traffic for other threads can never evict a live + * observation; a genuinely newer observation for the same thread replaces the + * old one. + * + * A receipt pairs a wall-clock instant with a monotonic baseline so elapsed + * time is measured monotonically and a browser wall-clock change cannot + * fabricate a silence age. This is not a clock-sync service: it only remembers + * when bytes actually arrived. + * + * @module postStartObservationReceipt + */ +export type PostStartObservationReceipt = { + readonly wallMs: number; + readonly monotonicMs: number; +}; + +/** Minimal shape the shell state exposes for receipt recording. */ +export type PostStartObservationThread = { + readonly id: string; + readonly postStartActivity?: + | { readonly observedAt?: string | null | undefined } + | null + | undefined; +}; + +/** + * Upper bound on tracked environment/thread pairs. Far larger than any real + * shell (one entry per current observation, not per sample), so it only guards + * against unbounded growth from pathological churn. + */ +const MAX_TRACKED_THREADS = 4_096; + +type StoredReceipt = { readonly observedAt: string; readonly receipt: PostStartObservationReceipt }; + +const receiptsByThread = new Map(); + +export function monotonicNowMs(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : // @effect-diagnostics-next-line globalDate:off + Date.now(); +} + +function threadKey(environmentId: string, threadId: string): string { + return `${environmentId}:${threadId}`; +} + +function makeReceipt(now: { + readonly wallMs?: number | undefined; + readonly monotonicMs?: number | undefined; +}): PostStartObservationReceipt { + return { + // @effect-diagnostics-next-line globalDate:off + wallMs: now.wallMs ?? Date.now(), + monotonicMs: now.monotonicMs ?? monotonicNowMs(), + }; +} + +/** + * Record (or reuse) the receipt for the observation currently held for a + * thread. Re-sighting the same `observedAt` keeps the original receipt; + * a genuinely new `observedAt` replaces it. + */ +export function recordPostStartObservationReceipt( + environmentId: string, + threadId: string, + observedAt: string, + now: { + readonly wallMs?: number | undefined; + readonly monotonicMs?: number | undefined; + } = {}, +): PostStartObservationReceipt { + const key = threadKey(environmentId, threadId); + const existing = receiptsByThread.get(key); + if (existing !== undefined && existing.observedAt === observedAt) return existing.receipt; + const receipt = makeReceipt(now); + if (existing === undefined && receiptsByThread.size >= MAX_TRACKED_THREADS) { + const oldest = receiptsByThread.keys().next().value; + if (oldest !== undefined) receiptsByThread.delete(oldest); + } + receiptsByThread.set(key, { observedAt, receipt }); + return receipt; +} + +/** + * Record receipts for every observation accepted in a shell snapshot. Also + * drops this environment's entries for threads that no longer carry an + * observation, reusing the shell's own lifecycle instead of a separate sweep. + */ +export function recordSnapshotObservationReceipts( + environmentId: string, + threads: ReadonlyArray, + now: { + readonly wallMs?: number | undefined; + readonly monotonicMs?: number | undefined; + } = {}, +): void { + // @effect-diagnostics-next-line globalDate:off + const wallMs = now.wallMs ?? Date.now(); + const monotonicMs = now.monotonicMs ?? monotonicNowMs(); + const present = new Set(); + for (const thread of threads) { + const observedAt = thread.postStartActivity?.observedAt ?? null; + if (observedAt === null) continue; + present.add(threadKey(environmentId, thread.id)); + recordPostStartObservationReceipt(environmentId, thread.id, observedAt, { + wallMs, + monotonicMs, + }); + } + const prefix = `${environmentId}:`; + for (const key of [...receiptsByThread.keys()]) { + if (key.startsWith(prefix) && !present.has(key)) receiptsByThread.delete(key); + } +} + +/** + * The receipt for an observation, or null when none was recorded for this + * thread's current `observedAt`. Callers pass that null through as honest + * "unknown receipt" rather than inventing one at consumption time. + */ +export function resolvePostStartObservationReceipt( + environmentId: string, + threadId: string, + observedAt: string, +): PostStartObservationReceipt | null { + const entry = receiptsByThread.get(threadKey(environmentId, threadId)); + return entry !== undefined && entry.observedAt === observedAt ? entry.receipt : null; +} + +export function resetPostStartObservationReceipts(): void { + receiptsByThread.clear(); +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0d933c39f8ba..a9555070a193 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, ORCHESTRATION_WS_METHODS, + TurnId, type OrchestrationShellSnapshot, type OrchestrationShellStreamItem, } from "@t3tools/contracts"; @@ -23,7 +24,15 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + derivePostStartActivityAnchors, + resolvePostStartActivity, +} from "@t3tools/shared/postStartActivity"; import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts"; +import { + resetPostStartObservationReceipts, + resolvePostStartObservationReceipt, +} from "./postStartObservationReceipt.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -41,6 +50,13 @@ const PREPARED: PreparedConnection = { target: TARGET, }; +// The receipt registry stamps real wall-clock instants, so the observation +// under test must use the same real clock rather than Effect's TestClock. +function realNowIso(offsetMs = 0): string { + // @effect-diagnostics-next-line globalDate:off + return new Date(Date.now() + offsetMs).toISOString(); +} + const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { snapshotSequence: 1, projects: [], @@ -451,4 +467,196 @@ describe("environment shell synchronization", () => { expect(yield* Ref.get(loaderCalls)).toBe(2); }), ); + + it.effect("records each accepted observation's receipt in the client state (C2)", () => + Effect.gen(function* () { + resetPostStartObservationReceipts(); + const observedAt = realNowIso(); + const quietSince = realNowIso(-6 * 60_000); + const turnId = TurnId.make("turn-1"); + const snapshot: OrchestrationShellSnapshot = { + ...LIVE_SHELL_SNAPSHOT, + threads: [{ id: "thread-a", postStartActivity: { observedAt } } as never], + }; + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService( + ShellSnapshotLoader, + ShellSnapshotLoader.of({ load: () => Effect.succeed(Option.none()) }), + ), + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(events, { kind: "snapshot", snapshot }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + Option.isSome(state.snapshot) && + state.snapshot.value.threads.some((thread) => thread.id === "thread-a"), + ), + Stream.runHead, + ); + + // No view or notification preference consumed the observation: the state + // path records the receipt itself at acceptance. + const first = resolvePostStartObservationReceipt( + TARGET.environmentId, + "thread-a", + observedAt, + ); + expect(first).not.toBeNull(); + + // The consumer derivation reads that same basis six minutes later and + // still classifies the observation as quiet silence, not clock + // uncertainty. A receipt captured at UI consumption would instead be six + // minutes late and make the offset unsupported. + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: { + turnId, + state: "running", + requestedAt: quietSince, + startedAt: quietSince, + completedAt: null, + }, + session: { status: "running", activeTurnId: turnId }, + live: { + lastProviderActivityAt: quietSince, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt, + turnId, + }, + receivedAtMs: first!.wallMs, + receivedMonotonicMs: first!.monotonicMs, + }); + const observation = resolvePostStartActivity(anchors, first!.wallMs + 6 * 60_000, { + nowMonotonicMs: first!.monotonicMs + 6 * 60_000, + }); + expect(observation.status).toBe("quiet"); + + // Counterfactual: a receipt captured six minutes late (the old first-UI + // consumption) would be classified as unsupported clock skew instead. + const lateAnchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: { + turnId, + state: "running", + requestedAt: quietSince, + startedAt: quietSince, + completedAt: null, + }, + session: { status: "running", activeTurnId: turnId }, + live: { + lastProviderActivityAt: quietSince, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt, + turnId, + }, + receivedAtMs: first!.wallMs + 6 * 60_000, + receivedMonotonicMs: first!.monotonicMs + 6 * 60_000, + }); + expect( + resolvePostStartActivity(lateAnchors, first!.wallMs + 6 * 60_000, { + nowMonotonicMs: first!.monotonicMs + 6 * 60_000, + }).status, + ).toBe("unknown"); + + // An unrelated update for another thread re-sends thread-a unchanged; the + // receipt must not be re-dated. + yield* Queue.offer(events, { + kind: "thread-upserted", + sequence: 2, + thread: { + id: "thread-b", + postStartActivity: { observedAt: "2026-06-06T00:01:00.000Z" }, + } as never, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + Option.isSome(state.snapshot) && + state.snapshot.value.threads.some((thread) => thread.id === "thread-b"), + ), + Stream.runHead, + ); + expect(resolvePostStartObservationReceipt(TARGET.environmentId, "thread-a", observedAt)).toBe( + first, + ); + + // A genuinely newer observation for the same thread replaces the basis; + // the previous observation no longer resolves, so the re-receipt happens + // on the new bytes rather than on a later read. + const observedAt2 = "2026-06-06T00:02:00.000Z"; + yield* Queue.offer(events, { + kind: "thread-upserted", + sequence: 3, + thread: { id: "thread-a", postStartActivity: { observedAt: observedAt2 } } as never, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + Option.isSome(state.snapshot) && + state.snapshot.value.threads.some( + (thread) => + thread.id === "thread-a" && + (thread.postStartActivity?.observedAt ?? null) === observedAt2, + ), + ), + Stream.runHead, + ); + expect( + resolvePostStartObservationReceipt(TARGET.environmentId, "thread-a", observedAt), + ).toBeNull(); + const second = resolvePostStartObservationReceipt( + TARGET.environmentId, + "thread-a", + observedAt2, + ); + expect(second).not.toBeNull(); + expect(second).not.toBe(first); + }), + ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index b7f39b509462..b25b16864815 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -24,6 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; +import { recordSnapshotObservationReceipts } from "./postStartObservationReceipt.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -173,6 +174,13 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } yield* Ref.set(awaitingCompletion, waiting); if (next === initial) return; + // Stamp each accepted observation with the client instant it actually + // arrived, in the state path itself, before the new state is observable, so + // the receipt basis is independent of notification preferences and mounted + // views and a consumer reacting to the state change never races it. + if (Option.isSome(next.snapshot)) { + recordSnapshotObservationReceipts(environmentId, next.snapshot.value.threads); + } yield* SubscriptionRef.set(state, next); if (receivedSnapshot) { const session = yield* Ref.get(activeSubscriptionSession); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3e323e4964d5..c24d53025dc2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -670,6 +670,53 @@ export const OrchestrationThreadActivity = Schema.Struct({ }); export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; +/** + * One tool call the server has observed as outstanding. Mirrors the shared + * post-start derivation's tool shape so the shell can carry live observation + * to clients that cannot see the thread's activity list. + */ +export const OrchestrationOutstandingTool = Schema.Struct({ + toolCallId: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + itemType: Schema.NullOr(TrimmedNonEmptyString), + startedAt: IsoDateTime, + lastObservedAt: IsoDateTime, +}); +export type OrchestrationOutstandingTool = typeof OrchestrationOutstandingTool.Type; + +/** + * Server-observed post-start provider activity for a running thread. Live-only + * (no persistence): after a restart the shell omits it and clients fall back to + * the persisted turn origin. Optional so older peers still decode. + */ +export const OrchestrationPostStartActivity = Schema.Struct({ + lastProviderActivityAt: Schema.NullOr(IsoDateTime), + lastToolCompletedAt: Schema.NullOr(IsoDateTime), + outstandingTools: Schema.Array(OrchestrationOutstandingTool), + /** + * The turn this observation describes, or null while the accepted request is + * pending and the provider turn is not named yet. Lets a client reject a + * cached observation that belongs to a different turn. Optional so peers + * that predate the field still decode. + */ + turnId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** + * Tool ids the server has observed as completed this turn. Carried so a + * client can reconcile live evidence against persisted rows without + * resurrecting a finished call from stale progress in either direction. + * Optional so older peers still decode. + */ + completedToolIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** + * Server clock instant the shell was mapped. This is the observation-time + * basis: the client measures provider ages against the server's clock using + * this stamp instead of assuming the two clocks agree. Optional so peers + * that omit it fall back to their own clock. + */ + observedAt: Schema.optional(Schema.NullOr(IsoDateTime)), +}); +export type OrchestrationPostStartActivity = typeof OrchestrationPostStartActivity.Type; + const OrchestrationLatestTurnState = Schema.Literals([ "running", "interrupted", @@ -935,6 +982,9 @@ export const OrchestrationThreadShell = Schema.Struct({ }), ), ), + // Server-observed live provider activity for a running thread. Optional so + // old servers/clients interop; absent = unknown (fall back to turn origin). + postStartActivity: Schema.optional(Schema.NullOr(OrchestrationPostStartActivity)), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 9e929325a5d4..ee8f3e3f3810 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -99,6 +99,10 @@ "types": "./src/toolActivity.ts", "import": "./src/toolActivity.ts" }, + "./postStartActivity": { + "types": "./src/postStartActivity.ts", + "import": "./src/postStartActivity.ts" + }, "./favicon": { "types": "./src/favicon.ts", "import": "./src/favicon.ts" diff --git a/packages/shared/src/postStartActivity.test.ts b/packages/shared/src/postStartActivity.test.ts new file mode 100644 index 000000000000..9e41ad2473e8 --- /dev/null +++ b/packages/shared/src/postStartActivity.test.ts @@ -0,0 +1,827 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; +import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; + +import { + POST_START_SILENCE_THRESHOLD_MS, + derivePostStartActivityAnchors, + isProviderActivityKind, + resolvePostStartActivity, + type DerivePostStartActivityInput, +} from "./postStartActivity.ts"; + +const T0 = "2026-01-01T00:00:00.000Z"; +const BASE = DateTime.makeUnsafe(T0); +const T = (ms: number) => DateTime.formatIso(DateTime.add({ milliseconds: ms })(BASE)); +const MIN = 60_000; +const TURN_ID = TurnId.make("turn-1"); +const OLD_TURN_ID = TurnId.make("turn-0"); +const REMOTE_TURN_ID = TurnId.make("turn-remote"); + +function activity( + overrides: Partial & Pick, +): OrchestrationThreadActivity { + return { + id: overrides.id ?? EventId.make(`${overrides.kind}:${overrides.createdAt ?? T0}`), + tone: overrides.tone ?? "tool", + kind: overrides.kind, + summary: overrides.summary ?? overrides.kind, + payload: overrides.payload ?? {}, + turnId: overrides.turnId !== undefined ? overrides.turnId : TURN_ID, + createdAt: overrides.createdAt ?? T0, + ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), + }; +} + +function turnTool( + toolCallId: string, + at: string, + kind: "tool.started" | "tool.updated" | "tool.completed", + input: { title?: string; status?: string; sequence?: number } = {}, +): OrchestrationThreadActivity { + return activity({ + kind, + createdAt: at, + ...(input.sequence !== undefined ? { sequence: input.sequence } : {}), + payload: { + toolCallId, + itemType: "command_execution", + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + }, + }); +} + +const RUNNING_TURN = { + turnId: TURN_ID, + state: "running" as const, + requestedAt: T0, + startedAt: T0, + completedAt: null, +}; + +function anchorsFor( + input: Partial & { + activities: ReadonlyArray; + }, +) { + return derivePostStartActivityAnchors({ + activities: input.activities, + latestTurn: input.latestTurn ?? RUNNING_TURN, + session: input.session ?? { status: "running", activeTurnId: TURN_ID }, + ...(input.knownWait !== undefined ? { knownWait: input.knownWait } : {}), + }); +} + +describe("isProviderActivityKind", () => { + it("counts tool/task/provider/runtime progress", () => { + for (const kind of [ + "tool.started", + "tool.progress", + "tool.updated", + "tool.completed", + "task.started", + "task.progress", + "task.completed", + "turn.plan.updated", + "context-compaction", + "runtime.error", + "provider.turn.start.failed", + "runtime.warning", + ]) { + expect(isProviderActivityKind(kind), kind).toBe(true); + } + }); + + it("rejects user, approval, metadata, checkpoint and scaffolding events", () => { + for (const kind of [ + "user-input.requested", + "user-input.resolved", + "user-input.answer-submitted", + "approval.requested", + "approval.resolved", + "tool.denied", + "context-window.updated", + "checkpoint.captured", + "checkpoint.capture.failed", + "thread.state.changed", + "project-upserted", + "setup-script.started", + ]) { + expect(isProviderActivityKind(kind), kind).toBe(false); + } + }); +}); + +describe("derivePostStartActivityAnchors", () => { + it("is inactive with no running turn", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [activity({ kind: "tool.started", createdAt: T0 })], + latestTurn: { ...RUNNING_TURN, state: "completed", completedAt: T(5 * MIN) }, + session: { status: "ready", activeTurnId: null }, + }); + expect(anchors.active).toBe(false); + expect(anchors.turnId).toBeNull(); + expect(anchors.outstandingTool).toBeNull(); + }); + + it("scopes last activity and completion to the current turn only", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("old", T(-30 * MIN), "tool.started", { title: "old" }), + turnTool("old", T(-30 * MIN), "tool.completed", { title: "old" }), + activity({ + kind: "tool.updated", + createdAt: T(2 * MIN), + id: EventId.make("a"), + payload: { toolCallId: "x", title: "run" }, + }), + turnTool("y", T(MIN), "tool.completed", { title: "done" }), + ], + }); + expect(anchors.lastProviderActivityAt).toBe(T(2 * MIN)); + expect(anchors.lastToolCompletedAt).toBe(T(MIN)); + }); + + it("ignores metadata-only activity for recency", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("x", T(MIN), "tool.started", { title: "run" }), + turnTool("x", T(2 * MIN), "tool.completed", { title: "run" }), + activity({ + kind: "context-window.updated", + createdAt: T(4 * MIN), + id: EventId.make("ctx"), + }), + ], + }); + expect(anchors.lastProviderActivityAt).toBe(T(2 * MIN)); + }); + + it("correlates overlapping tools by toolCallId and never cross-clears", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("a", T0, "tool.started", { title: "A" }), + turnTool("b", T(1 * MIN), "tool.started", { title: "B" }), + turnTool("a", T(2 * MIN), "tool.completed", { title: "A" }), + ], + }); + expect(anchors.outstandingTools.map((tool) => tool.toolCallId)).toEqual(["b"]); + expect(anchors.lastToolCompletedAt).toBe(T(2 * MIN)); + expect(anchors.outstandingTool?.title).toBe("B"); + expect(anchors.outstandingTool?.startedAt).toBe(T(1 * MIN)); + }); + + it("treats a terminal tool.updated status as completion", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("a", T0, "tool.started", { title: "A" }), + turnTool("a", T(3 * MIN), "tool.updated", { title: "A", status: "failed" }), + ], + }); + expect(anchors.outstandingTools).toEqual([]); + expect(anchors.lastToolCompletedAt).toBe(T(3 * MIN)); + }); + + it("correlates the provider toolUseId alias and advances the call on progress", () => { + const aliased = anchorsFor({ + activities: [ + activity({ + kind: "tool.started", + createdAt: T0, + turnId: TURN_ID, + payload: { toolUseId: "claude-tool-1", title: "Bash" }, + }), + activity({ + kind: "tool.progress", + createdAt: T(4 * MIN), + turnId: TURN_ID, + payload: { toolUseId: "claude-tool-1", toolName: "Bash", elapsedSeconds: 240 }, + }), + ], + }); + expect(aliased.outstandingTools.map((tool) => tool.toolCallId)).toEqual(["claude-tool-1"]); + expect(aliased.outstandingTool?.lastObservedAt).toBe(T(4 * MIN)); + expect(aliased.lastProviderActivityAt).toBe(T(4 * MIN)); + }); + + it("never fabricates tool completion from a terminal turn or closed connection", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [ + turnTool("a", T0, "tool.started", { title: "A" }), + activity({ kind: "provider.turn.start.failed", createdAt: T(1 * MIN) }), + ], + latestTurn: { ...RUNNING_TURN, state: "error", completedAt: T(2 * MIN) }, + session: { status: "error", activeTurnId: null }, + }); + expect(anchors.lastToolCompletedAt).toBeNull(); + expect(anchors.active).toBe(false); + }); + + it("excludes late activities from an earlier turn", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [ + activity({ + kind: "tool.started", + createdAt: T(10 * MIN), + turnId: OLD_TURN_ID, + payload: { toolCallId: "a", title: "late-old" }, + }), + turnTool("b", T(1 * MIN), "tool.started", { title: "current" }), + ], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + }); + expect(anchors.lastProviderActivityAt).toBe(T(1 * MIN)); + expect(anchors.outstandingTools.map((tool) => tool.title)).toEqual(["current"]); + }); + + it("observes the pending-start shape (starting session, no active turn, no latest turn)", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: null, + session: { status: "starting", activeTurnId: null }, + pendingStartedAt: T0, + }); + expect(anchors.active).toBe(true); + expect(anchors.turnId).toBeNull(); + expect(anchors.turnStartedAt).toBe(T0); + // The submitted request time anchors the warning before any provider event. + expect(resolvePostStartActivity(anchors, Date.parse(T(5 * MIN))).status).toBe("quiet"); + }); + + it("does not keep warning when the latest turn has already ended under a lagging session", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [turnTool("a", T0, "tool.started")], + latestTurn: { ...RUNNING_TURN, state: "completed", completedAt: T(2 * MIN) }, + session: { status: "running", activeTurnId: TURN_ID }, + }); + expect(anchors.active).toBe(false); + expect(resolvePostStartActivity(anchors, Date.parse(T(60 * MIN))).status).toBe("inactive"); + }); + + it("ignores usage-only task.progress rows", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("a", T0, "tool.started", { title: "run" }), + activity({ + kind: "task.progress", + createdAt: T(4 * MIN), + turnId: TURN_ID, + payload: { taskId: "t1", usageSnapshot: true, typedUsage: { inputTokens: 1 } }, + }), + ], + }); + expect(anchors.lastProviderActivityAt).toBe(T0); + }); + + it("merges live server-clock observation over persisted rows", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: T(4 * MIN), + lastToolCompletedAt: T(3 * MIN), + outstandingTools: [ + { + toolCallId: "a", + title: "Bash", + itemType: null, + startedAt: T0, + lastObservedAt: T(4 * MIN), + }, + ], + }, + }); + expect(anchors.lastProviderActivityAt).toBe(T(4 * MIN)); + expect(anchors.lastToolCompletedAt).toBe(T(3 * MIN)); + expect(anchors.outstandingTool?.lastObservedAt).toBe(T(4 * MIN)); + expect(resolvePostStartActivity(anchors, Date.parse(T(4 * MIN + 1_000))).status).toBe("active"); + }); +}); + +describe("resolvePostStartActivity", () => { + it("does not warn on a fresh turn with recent activity", () => { + const anchors = anchorsFor({ activities: [turnTool("a", T(1 * MIN), "tool.started")] }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(1 * MIN))); + expect(observation.status).toBe("active"); + expect(observation.episodeKey).toBeNull(); + }); + + it("warns at the threshold even when the turn never produced an event", () => { + const anchors = anchorsFor({ activities: [] }); + expect(resolvePostStartActivity(anchors, Date.parse(T(5 * MIN - 1))).status).toBe("active"); + const atThreshold = resolvePostStartActivity(anchors, Date.parse(T(5 * MIN))); + expect(atThreshold.status).toBe("quiet"); + expect(atThreshold.quietSinceAt).toBe(T0); + expect(atThreshold.episodeKey).toBe(`turn-1:${Date.parse(T0)}`); + }); + + it("carries the outstanding tool identity and its own age", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("a", T0, "tool.started", { title: "npm test" }), + turnTool("a", T(2 * MIN), "tool.updated", { title: "npm test" }), + ], + }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(8 * MIN))); + expect(observation.status).toBe("quiet"); + expect(observation.outstandingTool?.title).toBe("npm test"); + expect(observation.outstandingToolAgeMs).toBe(6 * MIN); + expect(observation.lastProviderActivityAgeMs).toBe(6 * MIN); + }); + + it("resets on resumption and opens a new episode on a later silence", () => { + const firstAnchor = anchorsFor({ activities: [turnTool("a", T0, "tool.started")] }); + const first = resolvePostStartActivity(firstAnchor, Date.parse(T(6 * MIN))); + expect(first.status).toBe("quiet"); + + const resumedAnchor = anchorsFor({ + activities: [turnTool("a", T0, "tool.started"), turnTool("a", T(6 * MIN), "tool.updated")], + }); + expect(resolvePostStartActivity(resumedAnchor, Date.parse(T(6 * MIN + 1))).status).toBe( + "active", + ); + + const second = resolvePostStartActivity(resumedAnchor, Date.parse(T(12 * MIN))); + expect(second.status).toBe("quiet"); + expect(second.episodeKey).not.toBe(first.episodeKey); + expect(second.quietSinceAt).toBe(T(6 * MIN)); + }); + + it("does not warn while a user decision is pending", () => { + const anchors = anchorsFor({ activities: [], knownWait: "approval" }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(30 * MIN))); + expect(observation.status).toBe("waiting"); + expect(observation.episodeKey).toBeNull(); + }); + + it("reports observation uncertainty when disconnected instead of asserting a stop", () => { + const anchors = anchorsFor({ activities: [turnTool("a", T(MIN), "tool.started")] }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(30 * MIN)), { + connection: "disconnected", + }); + expect(observation.status).toBe("unknown"); + expect(observation.episodeKey).toBeNull(); + expect(observation.lastProviderActivityAt).toBe(T(MIN)); + }); + + it("keeps background-only work from resurrecting a finished turn's warning", () => { + // Foreground turn completed; background liveness is carried elsewhere and + // must not reopen this turn's episode. + const anchors = derivePostStartActivityAnchors({ + activities: [turnTool("a", T0, "tool.started"), turnTool("a", T(MIN), "tool.completed")], + latestTurn: { ...RUNNING_TURN, state: "completed", completedAt: T(2 * MIN) }, + session: { status: "ready", activeTurnId: null }, + }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(60 * MIN))); + expect(observation.status).toBe("inactive"); + expect(observation.episodeKey).toBeNull(); + }); + + it("handles timezone-equivalent, invalid, missing and future timestamps", () => { + // 05:00+05:00 === 00:00Z; the later absolute instant (00:01Z) wins even + // though its wall-clock string sorts earlier. + const equivalent = anchorsFor({ + activities: [ + turnTool("a", "2026-01-01T05:00:00.000+05:00", "tool.started"), + turnTool("b", "2026-01-01T00:01:00.000Z", "tool.started"), + ], + }); + expect(equivalent.lastProviderActivityAt).toBe("2026-01-01T00:01:00.000Z"); + expect(Date.parse("2026-01-01T05:00:00.000+05:00")).toBe(Date.parse(T0)); + + const invalid = anchorsFor({ + activities: [turnTool("a", "not-a-date", "tool.started")], + }); + // Invalid activity timestamps cannot anchor the clock; the turn origin is used. + expect(invalid.lastProviderActivityAt).toBeNull(); + expect(resolvePostStartActivity(invalid, Date.parse(T(6 * MIN))).quietSinceAt).toBe(T0); + + const future = anchorsFor({ + activities: [turnTool("a", T(10 * MIN), "tool.started")], + }); + // A future (clock-skewed) origin is an unsupported clock relationship, not + // freshness: report honest uncertainty instead of clamping it to "active". + const futureObservation = resolvePostStartActivity(future, Date.parse(T(3 * MIN))); + expect(futureObservation.status).toBe("unknown"); + expect(futureObservation.lastProviderActivityAgeMs).toBeNull(); + + // A small skew within tolerance is still treated as recent, never negative. + const nearFuture = anchorsFor({ + activities: [turnTool("a", "2026-01-01T00:00:30.000Z", "tool.started")], + }); + const nearFutureObservation = resolvePostStartActivity(nearFuture, Date.parse(T0)); + expect(nearFutureObservation.status).toBe("active"); + expect(nearFutureObservation.lastProviderActivityAgeMs).toBe(0); + + const noOrigin = derivePostStartActivityAnchors({ + activities: [], + latestTurn: { + turnId: TURN_ID, + state: "running", + requestedAt: "bad", + startedAt: "also-bad", + completedAt: null, + }, + session: { status: "running", activeTurnId: TURN_ID }, + }); + expect(resolvePostStartActivity(noOrigin, Date.parse(T(6 * MIN))).status).toBe("unknown"); + }); + + it("ages from the persisted origin, so opening an old turn does not restart it", () => { + // The quiet origin is the last persisted activity, not the time the client + // mounted, so an hours-old quiet turn is already past the threshold. + const anchors = anchorsFor({ activities: [turnTool("a", T0, "tool.started")] }); + const hoursLater = Date.parse(T(3 * 60 * MIN)); + const observation = resolvePostStartActivity(anchors, hoursLater); + expect(observation.status).toBe("quiet"); + expect(observation.quietForMs).toBe(3 * 60 * MIN); + }); + + it("keeps one stable episode key across ticks and threshold defaults to five minutes", () => { + const anchors = anchorsFor({ activities: [turnTool("a", T0, "tool.started")] }); + const atThreshold = resolvePostStartActivity(anchors, Date.parse(T(5 * MIN))); + const later = resolvePostStartActivity(anchors, Date.parse(T(6 * MIN))); + expect(later.episodeKey).toBe(atThreshold.episodeKey); + expect(POST_START_SILENCE_THRESHOLD_MS).toBe(5 * MIN); + // A caller-supplied threshold overrides the default for tests/tuning. + expect( + resolvePostStartActivity(anchors, Date.parse(T(2 * MIN)), { thresholdMs: MIN }).status, + ).toBe("quiet"); + }); + + it("canonicalizes equivalent-instants to one episode key regardless of offset", () => { + const zulu = anchorsFor({ + activities: [turnTool("a", "2026-01-01T00:00:00.000Z", "tool.started")], + }); + const offset = anchorsFor({ + activities: [turnTool("a", "2026-01-01T05:00:00.000+05:00", "tool.started")], + }); + const nowMs = Date.parse(T(6 * MIN)); + expect(resolvePostStartActivity(offset, nowMs).episodeKey).toBe( + resolvePostStartActivity(zulu, nowMs).episodeKey, + ); + }); + + it("keeps local and remote environments isolated by turn scoping", () => { + const local = anchorsFor({ activities: [turnTool("a", T(4 * MIN), "tool.started")] }); + const remote = derivePostStartActivityAnchors({ + activities: [turnTool("a", T(4 * MIN), "tool.started")], + latestTurn: { ...RUNNING_TURN, turnId: REMOTE_TURN_ID }, + session: { status: "running", activeTurnId: REMOTE_TURN_ID }, + }); + // Same event shape, different turn identity: the remote anchors only see + // rows belonging to the remote turn, so they do not inherit the local one. + expect(local.turnId).toBe("turn-1"); + expect(local.lastProviderActivityAt).toBe(T(4 * MIN)); + expect(remote.turnId).toBe("turn-remote"); + expect(remote.lastProviderActivityAt).toBeNull(); + expect(resolvePostStartActivity(remote, Date.parse(T(10 * MIN))).quietSinceAt).toBe(T0); + }); +}); + +describe("tool lifecycle reconciliation", () => { + it("keeps a completed call finished when stale live evidence still lists it", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [ + turnTool("a", T0, "tool.started", { title: "A" }), + turnTool("a", T(2 * MIN), "tool.completed", { title: "A" }), + ], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: T(3 * MIN), + lastToolCompletedAt: null, + outstandingTools: [ + { + toolCallId: "a", + title: "A", + itemType: null, + startedAt: T0, + lastObservedAt: T(3 * MIN), + }, + ], + }, + }); + expect(anchors.outstandingTools).toEqual([]); + }); + + it("drops a persisted outstanding call when newer live evidence completes it", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [turnTool("a", T0, "tool.started", { title: "A" })], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: T(3 * MIN), + lastToolCompletedAt: T(3 * MIN), + outstandingTools: [], + completedToolIds: ["a"], + }, + }); + expect(anchors.outstandingTools).toEqual([]); + expect(anchors.lastToolCompletedAt).toBe(T(3 * MIN)); + }); + + it("does not reopen a completed call on a later progress update", () => { + const anchors = anchorsFor({ + activities: [ + turnTool("a", T0, "tool.started", { title: "A" }), + turnTool("a", T(2 * MIN), "tool.completed", { title: "A" }), + activity({ + kind: "tool.progress", + createdAt: T(3 * MIN), + id: EventId.make("late-progress"), + payload: { toolCallId: "a", title: "A" }, + }), + ], + }); + expect(anchors.outstandingTools).toEqual([]); + expect(anchors.lastToolCompletedAt).toBe(T(2 * MIN)); + }); + + it("keeps overlapping tools independent under completion", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [ + turnTool("a", T0, "tool.started", { title: "A" }), + turnTool("b", T(MIN), "tool.started", { title: "B" }), + ], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: T(2 * MIN), + lastToolCompletedAt: T(2 * MIN), + outstandingTools: [ + { + toolCallId: "b", + title: "B", + itemType: null, + startedAt: T(MIN), + lastObservedAt: T(2 * MIN), + }, + ], + completedToolIds: ["a"], + }, + }); + expect(anchors.outstandingTools.map((tool) => tool.toolCallId)).toEqual(["b"]); + }); + + it("preserves a tool whose start is outside retained history", () => { + const anchors = anchorsFor({ + activities: [ + activity({ + kind: "tool.progress", + createdAt: T(4 * MIN), + turnId: TURN_ID, + payload: { toolCallId: "orphan", title: "Orphan" }, + }), + ], + }); + expect(anchors.outstandingTools.map((tool) => tool.toolCallId)).toEqual(["orphan"]); + expect(anchors.outstandingTool?.startedAt).toBe(T(4 * MIN)); + }); +}); + +describe("observation clock basis", () => { + const SERVER_T0 = T0; + + it("prefers the server observation over a skewed stored provider timestamp", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [ + activity({ + kind: "tool.started", + createdAt: "2026-01-01T12:00:00.000Z", + payload: { toolCallId: "a", title: "A" }, + }), + ], + latestTurn: { + turnId: TURN_ID, + state: "running", + requestedAt: "2026-01-01T12:00:00.000Z", + startedAt: "2026-01-01T12:00:00.000Z", + completedAt: null, + }, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: SERVER_T0, + }, + receivedAtMs: Date.parse(SERVER_T0), + }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(1 * MIN))); + expect(observation.status).toBe("active"); + }); + + it("reports uncertainty when the browser clock is far ahead of the server", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: SERVER_T0, + }, + // Browser reads 01:00 when the server stamped 00:00. + receivedAtMs: Date.parse(T(60 * MIN)), + }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(60 * MIN + 1_000))); + expect(observation.status).toBe("unknown"); + expect(observation.lastProviderActivityAgeMs).toBeNull(); + expect(observation.lastProviderActivityAt).toBe(SERVER_T0); + }); + + it("reports uncertainty when the browser clock is far behind the server", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: SERVER_T0, + }, + receivedAtMs: Date.parse(T(-60 * MIN)), + }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(-60 * MIN + 1_000))); + expect(observation.status).toBe("unknown"); + }); + + it("measures quiet on the server basis across a clock offset", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: SERVER_T0, + }, + // A consistent 10s offset between the clocks. + receivedAtMs: Date.parse(T(10_000)), + }); + // At 4 minutes in server terms, still active; at 5 minutes, quiet. + expect(resolvePostStartActivity(anchors, Date.parse(T(10_000 + 4 * MIN))).status).toBe( + "active", + ); + const quiet = resolvePostStartActivity(anchors, Date.parse(T(10_000 + 5 * MIN))); + expect(quiet.status).toBe("quiet"); + expect(quiet.episodeKey).toBe(`${TURN_ID}:${Date.parse(SERVER_T0)}`); + }); + + it("re-establishes the basis when a fresh observation lands", () => { + const first = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: SERVER_T0, + }, + receivedAtMs: Date.parse(SERVER_T0), + }); + expect(resolvePostStartActivity(first, Date.parse(T(6 * MIN))).status).toBe("quiet"); + + const resumed = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: T(6 * MIN), + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: T(6 * MIN), + }, + receivedAtMs: Date.parse(T(6 * MIN + 2_000)), + }); + expect(resolvePostStartActivity(resumed, Date.parse(T(6 * MIN + 2_000))).status).toBe("active"); + }); + + it("keeps a fresh live tool observation over skewed persisted chronology", () => { + // The persisted row carries a provider timestamp twelve hours ahead of the + // server observation of the same call. + const skewed = "2026-01-01T12:00:00.000Z"; + const anchors = derivePostStartActivityAnchors({ + activities: [ + activity({ + kind: "tool.started", + createdAt: skewed, + payload: { toolCallId: "a", title: "A" }, + }), + ], + latestTurn: { ...RUNNING_TURN, requestedAt: skewed, startedAt: skewed }, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [ + { + toolCallId: "a", + title: "A", + itemType: null, + startedAt: SERVER_T0, + lastObservedAt: SERVER_T0, + }, + ], + observedAt: SERVER_T0, + }, + receivedAtMs: Date.parse(SERVER_T0), + }); + const observation = resolvePostStartActivity(anchors, Date.parse(T(1 * MIN))); + expect(anchors.outstandingTool?.lastObservedAt).toBe(SERVER_T0); + expect(observation.outstandingToolAgeMs).toBe(60_000); + expect(observation.status).toBe("active"); + }); + + it("measures elapsed monotonically so a wall-clock jump cannot fabricate silence", () => { + const receiptWallMs = Date.parse(SERVER_T0); + const receiptMonotonicMs = 1_000; + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: RUNNING_TURN, + session: { status: "running", activeTurnId: TURN_ID }, + live: { + lastProviderActivityAt: SERVER_T0, + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: SERVER_T0, + }, + receivedAtMs: receiptWallMs, + receivedMonotonicMs: receiptMonotonicMs, + }); + // Wall clock jumps an hour, but only ten real seconds have elapsed: the + // observation must not read as an hour of silence. + const jumped = resolvePostStartActivity(anchors, Date.parse(T(60 * MIN)), { + nowMonotonicMs: receiptMonotonicMs + 10_000, + }); + expect(jumped.status).toBe("active"); + expect(jumped.lastProviderActivityAgeMs).toBe(10_000); + + // Real monotonic silence past the threshold still warns. + const later = resolvePostStartActivity(anchors, Date.parse(T(60 * MIN)), { + nowMonotonicMs: receiptMonotonicMs + 6 * MIN, + }); + expect(later.status).toBe("quiet"); + }); + + it("rejects a cached observation from a different turn", () => { + const turnB = TurnId.make("turn-b"); + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: { + turnId: turnB, + state: "running", + requestedAt: T0, + startedAt: T0, + completedAt: null, + }, + session: { status: "running", activeTurnId: turnB }, + live: { + turnId: "turn-a", + lastProviderActivityAt: T(4 * MIN), + lastToolCompletedAt: null, + outstandingTools: [ + { + toolCallId: "stale-a", + title: "Stale", + itemType: null, + startedAt: T0, + lastObservedAt: T(4 * MIN), + }, + ], + observedAt: T(4 * MIN), + }, + receivedAtMs: Date.parse(T(4 * MIN)), + }); + expect(anchors.outstandingTools).toEqual([]); + expect(anchors.lastProviderActivityAt).toBeNull(); + expect(anchors.observingServerClock).toBe(false); + }); + + it("accepts a pending observation while no turn is named", () => { + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: null, + session: { status: "starting", activeTurnId: null }, + pendingStartedAt: T0, + live: { + turnId: null, + lastProviderActivityAt: T(2 * MIN), + lastToolCompletedAt: null, + outstandingTools: [], + observedAt: T(2 * MIN), + }, + receivedAtMs: Date.parse(T(2 * MIN)), + }); + expect(anchors.active).toBe(true); + expect(anchors.lastProviderActivityAt).toBe(T(2 * MIN)); + }); +}); diff --git a/packages/shared/src/postStartActivity.ts b/packages/shared/src/postStartActivity.ts new file mode 100644 index 000000000000..64500156ef42 --- /dev/null +++ b/packages/shared/src/postStartActivity.ts @@ -0,0 +1,711 @@ +/** + * Post-start activity visibility. + * + * A running turn is not proof of progress. Providers can go quiet while the + * turn is still live, and the existing status surface (a pulsing "Working" + * pill plus wall-clock turn duration) cannot tell recent progress from + * silence, a known long-running tool, or a connection we can no longer + * observe. This module derives that distinction from the events the server + * already persists, with deterministic event semantics and no model + * interpretation. + * + * Sources of truth (no new state is produced here): + * - `OrchestrationThreadActivity` rows: provider/tool/task lifecycle with a + * `turnId`, a monotonically ordered `sequence` when present, and + * `createdAt`. `tool.*` rows carry a stable `toolCallId` (or the provider's + * `toolUseId` alias) used to correlate overlapping tool calls. + * - The environment shell's live observation (recorded by ingestion on the + * server clock): assistant/reasoning text and tool heartbeats keep a + * `lastProviderActivityAt` that advances even when provider part/tool + * timestamps stay pinned to the part or tool start. These live observations + * are merged in, never persisted, so a replay or hydration of stored rows + * cannot manufacture resumed progress. + * - `OrchestrationLatestTurn` / `OrchestrationSession`: the current turn's + * `requestedAt`/`startedAt` and the session's active turn. + * + * Deliberately excluded as provider progress: user- and approval-driven + * events (`user-input.*`, `approval.*`, `tool.denied`), token/metadata + * bookkeeping (`context-window.updated` and usage-only `task.progress` rows), + * checkpoints, and thread/project scaffolding. Transport heartbeats, + * reconnects and UI renders are never activities, so they cannot masquerade + * as progress. + * + * @module postStartActivity + */ +import type { + OrchestrationLatestTurn, + OrchestrationSession, + OrchestrationThreadActivity, +} from "@t3tools/contracts"; + +import { compareDateTimeStrings } from "./dateTime.ts"; + +/** + * Conservative default for the first slice. Five minutes is long enough that + * ordinary reasoning pauses and slow first tokens do not trip it, and short + * enough that a truly wedged turn becomes visible before a user gives up on + * it. This is a display threshold only: it never aborts, settles, fails, or + * otherwise mutates the turn. + */ +export const POST_START_SILENCE_THRESHOLD_MS = 5 * 60_000; + +/** + * A provider origin further ahead than this than the observing clock is not a + * freshness signal — it is an unsupported clock relationship. Represent it as + * honest uncertainty instead of clamping it to "just happened". + */ +export const POST_START_FUTURE_TOLERANCE_MS = 60_000; + +export type PostStartOutstandingTool = { + readonly toolCallId: string; + /** Provider title, or a neutral fallback; never assistant prose. */ + readonly title: string; + readonly itemType: string | null; + readonly startedAt: string; + /** Last `tool.updated`/`tool.progress` time for this call; the age signal when it stalls. */ + readonly lastObservedAt: string; +}; + +export type PostStartKnownWait = "approval" | "input"; + +/** + * Provider progress observed directly by the server on its own clock. Unlike + * persisted activity rows these advance on text/reasoning deltas and tool + * heartbeats whose provider timestamps stay pinned to the start. + */ +export type PostStartLiveObservation = { + readonly lastProviderActivityAt: string | null; + readonly lastToolCompletedAt: string | null; + readonly outstandingTools: ReadonlyArray; + /** + * Tool ids the server has observed as completed this turn. Lets the merge + * drop a stale live outstanding call when persisted rows already show it + * finished (and vice versa). + */ + readonly completedToolIds?: ReadonlyArray | undefined; + /** + * Server clock instant the observation/shell was produced. Used as the + * observation-time basis so provider ages are not compared to a browser + * clock that may disagree. Absent on peers that predate the field. + */ + readonly observedAt?: string | null | undefined; + /** + * The turn this live observation describes, or null while it describes the + * accepted pending request (session `starting`, no provider turn id yet). + * Carried so a client whose current turn no longer matches the cached + * observation cannot consume another turn's recency or tools. + */ + readonly turnId?: string | null | undefined; +}; + +export type PostStartActivityAnchors = { + /** The turn being observed, or null when no turn is active. */ + readonly turnId: string | null; + /** True only while the provider is expected to be producing output. */ + readonly active: boolean; + /** + * Trustworthy current-turn origin to fall back to when no provider event + * ever arrived (`startedAt` once the provider accepted, else `requestedAt`). + */ + readonly turnStartedAt: string | null; + /** Last provider-originated activity in this turn, or null. */ + readonly lastProviderActivityAt: string | null; + /** Last real tool completion in this turn, or null. */ + readonly lastToolCompletedAt: string | null; + /** Outstanding tool calls in start order. */ + readonly outstandingTools: ReadonlyArray; + /** Most recently observed outstanding tool, or null. */ + readonly outstandingTool: PostStartOutstandingTool | null; + /** A pending user decision explains the quiet; suppresses the warning. */ + readonly knownWait: PostStartKnownWait | null; + /** + * True when the server's own live observation is the basis for this turn's + * ages, so the resolver should measure against the server clock. + */ + readonly observingServerClock: boolean; + /** + * Server clock minus client clock, in milliseconds, estimated when the live + * observation arrived. Null when the peer omitted its clock basis. A large + * magnitude is an unsupported relationship, not a freshness signal. + */ + readonly observationClockOffsetMs: number | null; + /** + * Client wall-clock instant the live observation was actually received. + * Paired with `receivedMonotonicMs` so elapsed time since receipt can be + * measured monotonically; a render, navigation or unrelated shell update is + * not a new receipt and must not re-date the observation. + */ + readonly receivedAtMs: number | null; + /** + * Client monotonic instant at receipt. Elapsed time is computed from this + * baseline, so a browser wall-clock change between observations cannot + * fabricate a silence age. + */ + readonly receivedMonotonicMs: number | null; +}; + +export type PostStartActivityStatus = "inactive" | "active" | "quiet" | "waiting" | "unknown"; + +export type PostStartActivityObservation = { + readonly status: PostStartActivityStatus; + readonly lastProviderActivityAt: string | null; + readonly lastProviderActivityAgeMs: number | null; + readonly lastToolCompletedAt: string | null; + readonly lastToolCompletedAgeMs: number | null; + readonly outstandingTool: PostStartOutstandingTool | null; + readonly outstandingToolAgeMs: number | null; + /** Instant silence began, or null when not quiet. */ + readonly quietSinceAt: string | null; + readonly quietForMs: number; + /** + * Stable identity of one silence episode (current turn + quiet origin). + * A resumption changes it, so a later silence is a new episode. Equivalent + * instants canonicalize to the same key regardless of offset. + */ + readonly episodeKey: string | null; +}; + +export type PostStartConnectionState = "live" | "disconnected"; + +export type DerivePostStartActivityInput = { + readonly activities: ReadonlyArray; + readonly latestTurn: Pick< + OrchestrationLatestTurn, + "turnId" | "state" | "requestedAt" | "startedAt" | "completedAt" + > | null; + readonly session: Pick | null; + /** From the shell's pending flags; a known wait is not silence. */ + readonly knownWait?: PostStartKnownWait | null; + /** + * Origin for a turn the server has accepted but not yet named (session + * `starting`, `activeTurnId` null, and possibly no latest turn). The shell's + * `latestUserMessageAt` is the submitted request time when present. + */ + readonly pendingStartedAt?: string | null; + /** Server-observed progress on its own clock; merged over persisted rows. */ + readonly live?: PostStartLiveObservation | null; + /** + * Client clock instant the live observation was received, used only to + * estimate the server/client clock relationship. Omit it to fall back to + * the local clock (older callers and tests). + */ + readonly receivedAtMs?: number | null; + /** + * Client monotonic instant paired with `receivedAtMs` at the same receipt. + * When both are supplied the resolver measures elapsed time monotonically, + * so a wall-clock change cannot invent a silence age. + */ + readonly receivedMonotonicMs?: number | null; +}; + +function parseMs(value: string | null | undefined): number | null { + if (value == null) return null; + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; +} + +function maxTimestamp(left: string | null, right: string | null): string | null { + const leftMs = parseMs(left); + const rightMs = parseMs(right); + if (leftMs === null && rightMs === null) return null; + if (leftMs === null) return right; + if (rightMs === null) return left; + return leftMs >= rightMs ? left : right; +} + +/** + * A provider event that represents the provider doing work (or reporting a + * failure). Everything else — user input, approvals, token metadata, + * checkpoints, thread scaffolding — is intentionally not progress. + */ +export function isProviderActivityKind(kind: string): boolean { + if (kind === "tool.denied") return false; + return ( + kind.startsWith("tool.") || + kind.startsWith("task.") || + kind.startsWith("provider.") || + kind.startsWith("runtime.") || + kind === "turn.plan.updated" || + kind === "context-compaction" + ); +} + +const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "declined", + "stopped", +]); + +function payloadRecord(activity: OrchestrationThreadActivity): Record | null { + const payload = activity.payload; + return payload !== null && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; +} + +function trimmed(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const next = value.trim(); + return next.length > 0 ? next : undefined; +} + +/** Usage-only rows share a kind prefix with meaningful progress; exclude them. */ +export function isMeaningfulProviderActivity(activity: { + readonly kind: string; + readonly payload: unknown; +}): boolean { + if (!isProviderActivityKind(activity.kind)) return false; + const payload = activity.payload; + const asRecord = + payload !== null && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; + return asRecord?.usageSnapshot !== true; +} + +/** Providers name the same tool call `toolCallId` or the `toolUseId` alias. */ +function toolCorrelationKey(payload: Record | null): string | undefined { + return trimmed(payload?.toolCallId) ?? trimmed(payload?.toolUseId); +} + +function toolTitle(payload: Record | null): string | undefined { + return trimmed(payload?.title) ?? trimmed(payload?.toolName); +} + +function activityOrder( + left: OrchestrationThreadActivity, + right: OrchestrationThreadActivity, +): number { + if ( + left.sequence !== undefined && + right.sequence !== undefined && + left.sequence !== right.sequence + ) { + return left.sequence - right.sequence; + } + if (left.sequence !== undefined && right.sequence === undefined) return 1; + if (left.sequence === undefined && right.sequence !== undefined) return -1; + const byTime = compareDateTimeStrings(left.createdAt, right.createdAt); + if (byTime !== 0) return byTime; + return left.id.localeCompare(right.id); +} + +type OutstandingToolsDerivation = { + readonly tools: ReadonlyArray; + readonly lastCompletedAt: string | null; + /** Tool ids seen to complete in this turn; completion is terminal per id. */ + readonly completedToolIds: ReadonlySet; +}; + +/** + * Correlate tool lifecycles by `toolCallId`/`toolUseId`. A completion — whether + * an explicit `tool.completed` or a terminal `tool.updated` status — clears + * exactly the call it identifies, so overlapping tools never clear each other. + * Completion is terminal for a tool id: a later progress/update for the same id + * is a late update, not a new call, and must not reopen it. `tool.progress` + * heartbeats advance the matching call's observation age without changing its + * identity. + */ +function deriveOutstandingTools( + activities: ReadonlyArray, +): OutstandingToolsDerivation { + const byKey = new Map(); + const completedToolIds = new Set(); + let lastCompletedAt: string | null = null; + for (const activity of [...activities].sort(activityOrder)) { + const payload = payloadRecord(activity); + const key = toolCorrelationKey(payload); + if (key === undefined) continue; + + if (activity.kind === "tool.started") { + if (completedToolIds.has(key)) continue; + byKey.set(key, { + toolCallId: key, + title: toolTitle(payload) ?? "Tool", + itemType: trimmed(payload?.itemType) ?? null, + startedAt: activity.createdAt, + lastObservedAt: activity.createdAt, + }); + continue; + } + + if (activity.kind === "tool.completed") { + byKey.delete(key); + completedToolIds.add(key); + lastCompletedAt = maxTimestamp(lastCompletedAt, activity.createdAt); + continue; + } + + if (activity.kind === "tool.updated" || activity.kind === "tool.progress") { + const status = trimmed(payload?.status); + if ( + activity.kind === "tool.updated" && + status !== undefined && + TERMINAL_TOOL_STATUSES.has(status) + ) { + byKey.delete(key); + completedToolIds.add(key); + lastCompletedAt = maxTimestamp(lastCompletedAt, activity.createdAt); + continue; + } + if (completedToolIds.has(key)) continue; + const existing = byKey.get(key); + if (existing === undefined) { + // An update/progress whose start aged out of retention still identifies + // a live call; record it from the first observation we do have. + byKey.set(key, { + toolCallId: key, + title: toolTitle(payload) ?? "Tool", + itemType: trimmed(payload?.itemType) ?? null, + startedAt: activity.createdAt, + lastObservedAt: activity.createdAt, + }); + continue; + } + byKey.set(key, { + ...existing, + title: toolTitle(payload) ?? existing.title, + lastObservedAt: + maxTimestamp(existing.lastObservedAt, activity.createdAt) ?? activity.createdAt, + }); + } + } + return { tools: [...byKey.values()], lastCompletedAt, completedToolIds }; +} + +/** + * Merge persisted and live tool observations by identity, keeping the latest + * age, but never resurrect a call either source has seen complete. Stale live + * evidence cannot reopen a persisted completion, and stale persisted evidence + * cannot reopen a live completion. + * + * When the live observation supplies the clock basis, live evidence wins for a + * call present in both sources. Persisted rows carry provider chronology, which + * can sit far ahead of the server; maxing it in would let a skewed stored + * timestamp beat a fresh server-observed heartbeat and under-report the tool's + * real age. + */ +function mergeOutstandingTools( + persisted: ReadonlyArray, + live: ReadonlyArray, + completedToolIds: ReadonlySet, + preferLiveTime: boolean, +): ReadonlyArray { + const byKey = new Map(); + for (const tool of [...persisted, ...live]) { + if (completedToolIds.has(tool.toolCallId)) continue; + const existing = byKey.get(tool.toolCallId); + if (existing === undefined) { + byKey.set(tool.toolCallId, tool); + continue; + } + if (preferLiveTime) { + byKey.set(tool.toolCallId, { ...existing, ...tool }); + continue; + } + const existingMs = parseMs(existing.lastObservedAt); + const nextMs = parseMs(tool.lastObservedAt); + if (nextMs !== null && (existingMs === null || nextMs >= existingMs)) { + byKey.set(tool.toolCallId, { ...existing, ...tool }); + } + } + return [...byKey.values()]; +} + +function latestOutstandingTool( + tools: ReadonlyArray, +): PostStartOutstandingTool | null { + if (tools.length === 0) return null; + return ( + [...tools] + .sort((left, right) => compareDateTimeStrings(left.lastObservedAt, right.lastObservedAt)) + .at(-1) ?? null + ); +} + +function isSessionActive(status: string | undefined): boolean { + return status === "running" || status === "starting"; +} + +/** + * Reduce the raw thread state to the stable anchors a ticking UI needs. + * Call this when thread data changes; call `resolvePostStartActivity` on the + * clock tick so only the notice re-renders each second. + */ +export function derivePostStartActivityAnchors( + input: DerivePostStartActivityInput, +): PostStartActivityAnchors { + const { latestTurn, session, live } = input; + const sessionActive = isSessionActive(session?.status); + const sessionActiveTurnId = session?.activeTurnId ?? null; + + // A stale session observation must not keep a terminal turn's warning alive: + // if the latest turn names the session's active turn and has already ended, + // the session state is lagging, not running. + const latestTurnEndedForActiveTurn = + latestTurn !== null && + sessionActiveTurnId !== null && + latestTurn.turnId === sessionActiveTurnId && + latestTurn.state !== "running"; + + const pendingStart = + session?.status === "starting" && sessionActiveTurnId === null && !latestTurnEndedForActiveTurn; + const pendingStartedAt = input.pendingStartedAt ?? null; + + const turnId = latestTurnEndedForActiveTurn + ? null + : (sessionActiveTurnId ?? (latestTurn?.state === "running" ? latestTurn.turnId : null) ?? null); + + const active = latestTurnEndedForActiveTurn + ? false + : sessionActive && (turnId !== null || session?.status === "starting"); + + const turnStartedAt = + latestTurn?.turnId === turnId && turnId !== null + ? parseMs(latestTurn.startedAt) !== null + ? latestTurn.startedAt + : latestTurn.requestedAt + : pendingStart + ? pendingStartedAt + : null; + + // The server's live observation is the observation-time basis when present: + // its instants are on the server clock, so a skewed persisted provider + // timestamp must not be maxed into the age. Estimate the server/client offset + // from the observation's own stamp when the client passed its receipt time. + // + // A cached observation belonging to a different turn must not supply this + // turn's recency or tools: a live record naming a turn the client is not + // currently on (or a pending record with no turn while a turn is named) is + // stale evidence, not progress. A peer that omits the field entirely is + // treated as unknown and accepted (forward/backward compatible). + const liveTurnId = live?.turnId; + const liveMatchesCurrentTurn = + live != null && + (liveTurnId === undefined + ? true + : liveTurnId === null + ? turnId === null + : liveTurnId === turnId); + const currentLive = liveMatchesCurrentTurn ? live : null; + const observingServerClock = currentLive != null; + const liveObservedAtMs = parseMs(currentLive?.observedAt ?? null); + const receivedAtMs = input.receivedAtMs ?? null; + const receivedMonotonicMs = input.receivedMonotonicMs ?? null; + const observationClockOffsetMs = + liveObservedAtMs !== null && receivedAtMs !== null ? liveObservedAtMs - receivedAtMs : null; + + if (!active) { + return { + turnId, + active: false, + turnStartedAt, + lastProviderActivityAt: null, + lastToolCompletedAt: null, + outstandingTools: [], + outstandingTool: null, + knownWait: input.knownWait ?? null, + observingServerClock, + observationClockOffsetMs, + receivedAtMs, + receivedMonotonicMs, + }; + } + + const turnActivities = + turnId === null ? [] : input.activities.filter((activity) => activity.turnId === turnId); + + let lastProviderActivityAt: string | null = null; + for (const activity of turnActivities) { + if (!isMeaningfulProviderActivity(activity)) continue; + lastProviderActivityAt = maxTimestamp(lastProviderActivityAt, activity.createdAt); + } + + const derived = deriveOutstandingTools(turnActivities); + const completedToolIds = new Set([ + ...derived.completedToolIds, + ...(currentLive?.completedToolIds ?? []), + ]); + const outstandingTools = mergeOutstandingTools( + derived.tools, + currentLive?.outstandingTools ?? [], + completedToolIds, + observingServerClock, + ); + + // Prefer the server clock for activity/completion recency; fall back to the + // persisted provider chronology only when the server has no live observation. + const liveLastActivity = currentLive?.lastProviderActivityAt ?? null; + const liveLastCompleted = currentLive?.lastToolCompletedAt ?? null; + + return { + turnId, + active: true, + turnStartedAt, + lastProviderActivityAt: liveLastActivity ?? lastProviderActivityAt, + lastToolCompletedAt: liveLastCompleted ?? derived.lastCompletedAt, + outstandingTools, + outstandingTool: latestOutstandingTool(outstandingTools), + knownWait: input.knownWait ?? null, + observingServerClock, + observationClockOffsetMs, + receivedAtMs, + receivedMonotonicMs, + }; +} + +/** + * Resolve the anchors at a given instant. `nowMs` is injected so tests use a + * controlled clock and the UI can tick without recomputing anchors. + */ +export function resolvePostStartActivity( + anchors: PostStartActivityAnchors, + nowMs: number, + options: { + readonly connection?: PostStartConnectionState; + readonly thresholdMs?: number; + readonly futureToleranceMs?: number; + /** + * Monotonic instant paired with `nowMs`. When the anchors carry their own + * receipt baseline, this lets the resolver advance the basis by measured + * monotonic elapsed time instead of trusting wall-clock movement. + */ + readonly nowMonotonicMs?: number; + } = {}, +): PostStartActivityObservation { + const thresholdMs = Math.max(0, options.thresholdMs ?? POST_START_SILENCE_THRESHOLD_MS); + const futureToleranceMs = Math.max( + 0, + options.futureToleranceMs ?? POST_START_FUTURE_TOLERANCE_MS, + ); + const connection = options.connection ?? "live"; + + // Elapsed time since the actual receipt, measured monotonically when the + // anchors carry that baseline. A wall-clock change between observations + // cannot then fabricate silence; without the baseline, fall back to wall now. + const basisNowMs = + typeof anchors.receivedAtMs === "number" && + typeof anchors.receivedMonotonicMs === "number" && + typeof options.nowMonotonicMs === "number" + ? anchors.receivedAtMs + Math.max(0, options.nowMonotonicMs - anchors.receivedMonotonicMs) + : nowMs; + + // Measured against the server clock when the live observation supplies the + // basis; the browser clock only supplies elapsed time on top of it. A clock + // relationship too far off to trust is honest uncertainty, never a warning. + const clockUnsupported = + anchors.observingServerClock && + anchors.observationClockOffsetMs !== null && + Math.abs(anchors.observationClockOffsetMs) > futureToleranceMs; + const nowBasisMs = + anchors.observationClockOffsetMs === null + ? basisNowMs + : basisNowMs + anchors.observationClockOffsetMs; + + const lastActivityMs = parseMs(anchors.lastProviderActivityAt); + const lastActivityAgeMs = + lastActivityMs === null ? null : Math.max(0, nowBasisMs - lastActivityMs); + const lastToolCompletedMs = parseMs(anchors.lastToolCompletedAt); + const lastToolCompletedAgeMs = + lastToolCompletedMs === null ? null : Math.max(0, nowBasisMs - lastToolCompletedMs); + const outstandingToolAgeMs = + anchors.outstandingTool === null + ? null + : (() => { + const observed = parseMs(anchors.outstandingTool.lastObservedAt); + return observed === null ? null : Math.max(0, nowBasisMs - observed); + })(); + + const base = { + lastProviderActivityAt: anchors.lastProviderActivityAt, + lastProviderActivityAgeMs: lastActivityAgeMs, + lastToolCompletedAt: anchors.lastToolCompletedAt, + lastToolCompletedAgeMs, + outstandingTool: anchors.outstandingTool, + outstandingToolAgeMs, + } as const; + + if (!anchors.active) { + return { ...base, status: "inactive", quietSinceAt: null, quietForMs: 0, episodeKey: null }; + } + + // A disconnected environment cannot be observed. Show the last known + // activity and say so; never assert the remote work stopped. + if (connection === "disconnected") { + return { ...base, status: "unknown", quietSinceAt: null, quietForMs: 0, episodeKey: null }; + } + + // Clocks that disagree beyond tolerance make every age untrustworthy; report + // the known instants but not an age, and never assert silence. + if (clockUnsupported) { + return { + ...base, + lastProviderActivityAgeMs: null, + lastToolCompletedAgeMs: null, + outstandingToolAgeMs: null, + status: "unknown", + quietSinceAt: null, + quietForMs: 0, + episodeKey: null, + }; + } + + // A pending approval/input is an explained wait, not unexplained silence. + if (anchors.knownWait !== null) { + return { ...base, status: "waiting", quietSinceAt: null, quietForMs: 0, episodeKey: null }; + } + + // With the server's own observation, the origin stays on the server clock; a + // persisted provider `turnStartedAt` must not be maxed back in. + const originAt = + anchors.observingServerClock && anchors.lastProviderActivityAt !== null + ? anchors.lastProviderActivityAt + : maxTimestamp(anchors.lastProviderActivityAt, anchors.turnStartedAt); + const originMs = parseMs(originAt); + + if (originAt === null || originMs === null) { + // No activity and no trustworthy current-turn origin: be honest. + return { ...base, status: "unknown", quietSinceAt: null, quietForMs: 0, episodeKey: null }; + } + + // An origin ahead of the observing clock is an unsupported clock + // relationship, not freshness. Do not clamp it to "just happened" and claim + // the turn is active; report honest uncertainty instead. The stored instant + // stays known, but its age is not trustworthy. + if (originMs - nowBasisMs > futureToleranceMs) { + return { + ...base, + lastProviderActivityAgeMs: + lastActivityMs !== null && lastActivityMs > nowBasisMs ? null : lastActivityAgeMs, + lastToolCompletedAgeMs: + lastToolCompletedMs !== null && lastToolCompletedMs > nowBasisMs + ? null + : lastToolCompletedAgeMs, + outstandingToolAgeMs: + anchors.outstandingTool !== null && + (parseMs(anchors.outstandingTool.lastObservedAt) ?? 0) > nowBasisMs + ? null + : outstandingToolAgeMs, + status: "unknown", + quietSinceAt: null, + quietForMs: 0, + episodeKey: null, + }; + } + + const quietForMs = Math.max(0, nowBasisMs - originMs); + if (quietForMs < thresholdMs) { + return { ...base, status: "active", quietSinceAt: null, quietForMs, episodeKey: null }; + } + + // Equivalent instants (different offsets) canonicalize to one episode key, + // so a re-hydration or a clock that moves the offset cannot fork identity. + return { + ...base, + status: "quiet", + quietSinceAt: originAt, + quietForMs, + episodeKey: `${anchors.turnId ?? "none"}:${originMs}`, + }; +}