From cfb1297534741757cd7823526d35d2c79a8c0c49 Mon Sep 17 00:00:00 2001 From: "STALL:S1" Date: Fri, 25 Sep 2026 18:49:02 -0400 Subject: [PATCH 01/10] fix(web): qualify silence during a running turn A running turn showed only a pulsing Working pill and wall-clock elapsed time, so unwedged-but-quiet providers, a known long-running tool, and an unobservable disconnected environment all looked alike. Derive a truthful post-start observation from the persisted thread activities (tool/task/provider/runtime kinds, correlated by toolCallId) plus the current turn's start/request and session state. Warn after five minutes of unexplained silence, name an outstanding tool and its own age, never treat metadata/user/approval events as progress, and show the last provider activity and last real tool completion honestly. The warning is display-only and clears on resumption or a terminal turn. Add the observation to the in-chat timeline via one lightweight notice row that self-ticks, with an optional once-per-episode in-app toast. --- apps/web/src/components/ChatView.tsx | 29 ++ .../chat/MessagesTimeline.logic.test.ts | 49 +++ .../components/chat/MessagesTimeline.logic.ts | 33 ++ .../src/components/chat/MessagesTimeline.tsx | 18 + .../chat/PostStartActivityNotice.tsx | 138 +++++++ packages/shared/package.json | 4 + packages/shared/src/postStartActivity.test.ts | 372 +++++++++++++++++ packages/shared/src/postStartActivity.ts | 390 ++++++++++++++++++ 8 files changed, 1033 insertions(+) create mode 100644 apps/web/src/components/chat/PostStartActivityNotice.tsx create mode 100644 packages/shared/src/postStartActivity.test.ts create mode 100644 packages/shared/src/postStartActivity.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b97411e7a44b..d6510015d3d7 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, @@ -2106,6 +2110,27 @@ 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. + const postStartActivityAnchors = useMemo(() => { + if (!activeThread) return null; + const knownWait: PostStartKnownWait | null = activeThreadShell?.hasPendingApprovals + ? "approval" + : activeThreadShell?.hasPendingUserInput + ? "input" + : null; + return derivePostStartActivityAnchors({ + activities: activeThread.activities ?? [], + latestTurn: activeLatestTurn, + session: activeThread.session ?? null, + knownWait, + }); + }, [ + activeThread, + activeLatestTurn, + activeThreadShell?.hasPendingApprovals, + activeThreadShell?.hasPendingUserInput, + ]); // 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 +9927,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/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..afad70b07851 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,54 @@ 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, + } 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"), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 57ed45a89d1d..b5e02d7a317a 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,8 @@ 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": + return a.anchors === (b as typeof a).anchors; 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.tsx b/apps/web/src/components/chat/PostStartActivityNotice.tsx new file mode 100644 index 000000000000..1990be559ef8 --- /dev/null +++ b/apps/web/src/components/chat/PostStartActivityNotice.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import { ClockIcon } 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 { useClientSettings } from "../../hooks/useSettings"; +import { toastManager } from "../ui/toast"; + +// One notification per silence episode, keyed by the episode's stable +// identity. A resumed turn produces a new key, so silence that recurs later +// can notify again; a tick or a remount cannot replay the same episode. +const notifiedEpisodeToastIds = new Map(); + +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`; +} + +/** + * Inline post-start status for a running turn. + * + * Renders nothing while the turn is producing recent progress. Once provider + * activity goes quiet past the threshold it names what is known: the + * outstanding tool (with its own age), or unexplained silence, or a + * disconnected environment we cannot observe. It never says the turn failed. + * + * The component self-ticks once a second so the warning appears exactly when + * the threshold is crossed without re-rendering the surrounding list. + */ +export function PostStartActivityNotice({ + anchors, + connection, +}: { + anchors: PostStartActivityAnchors; + connection: PostStartConnectionState; +}) { + const inAppNotificationsEnabled = useClientSettings( + (settings) => settings.inAppNotificationsEnabled, + ); + const [nowMs, setNowMs] = useState(() => Date.now()); + const lastNotifiedEpisodeRef = useRef(null); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), 1_000); + return () => window.clearInterval(id); + }, []); + + const observation = resolvePostStartActivity(anchors, nowMs, { connection }); + const { status, episodeKey } = observation; + + useEffect(() => { + const previousEpisode = lastNotifiedEpisodeRef.current; + if (status !== "quiet") { + if (previousEpisode !== null) { + const toastId = notifiedEpisodeToastIds.get(previousEpisode); + if (toastId !== undefined) { + toastManager.close(toastId); + notifiedEpisodeToastIds.delete(previousEpisode); + } + } + lastNotifiedEpisodeRef.current = null; + return; + } + lastNotifiedEpisodeRef.current = episodeKey; + if (!inAppNotificationsEnabled || episodeKey === null) return; + if (notifiedEpisodeToastIds.has(episodeKey)) return; + const toastId = toastManager.add({ + type: "warning", + title: "No recent provider activity", + description: "This turn may still be working. Open the thread to review its last activity.", + data: { hideCopyButton: true, leadingIcon: }, + }); + notifiedEpisodeToastIds.set(episodeKey, toastId); + }, [episodeKey, inAppNotificationsEnabled, status]); + + useEffect( + () => () => { + const episode = lastNotifiedEpisodeRef.current; + if (episode === null) return; + const toastId = notifiedEpisodeToastIds.get(episode); + if (toastId !== undefined) { + toastManager.close(toastId); + notifiedEpisodeToastIds.delete(episode); + } + }, + [], + ); + + if (status !== "quiet" && status !== "unknown") { + return null; + } + + const lastActivityDetail = + observation.lastProviderActivityAgeMs === null + ? "no provider activity observed yet" + : `last provider activity ${relativeAge(observation.lastProviderActivityAgeMs)}`; + const completionDetail = + observation.lastToolCompletedAt === null + ? "no tool completion observed" + : `last tool completed ${relativeAge(observation.lastToolCompletedAgeMs)}`; + + const mainLabel = + status === "unknown" + ? "Can't observe this turn's provider right now; its state is unknown." + : 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.`; + + return ( +
+
+ +
+ {mainLabel} +
+ {lastActivityDetail} · {completionDetail} +
+
+
+
+ ); +} 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..ffda7407b70f --- /dev/null +++ b/packages/shared/src/postStartActivity.test.ts @@ -0,0 +1,372 @@ +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([]); + }); + + 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"]); + }); +}); + +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:${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) activity must not produce a negative age. + expect(resolvePostStartActivity(future, Date.parse(T(3 * MIN))).status).toBe("active"); + + 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("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); + }); +}); diff --git a/packages/shared/src/postStartActivity.ts b/packages/shared/src/postStartActivity.ts new file mode 100644 index 000000000000..eac1a1bd0d9f --- /dev/null +++ b/packages/shared/src/postStartActivity.ts @@ -0,0 +1,390 @@ +/** + * 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` used to correlate + * overlapping tool calls. + * - `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`), 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; + +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` time for this call; the age signal when it stalls. */ + readonly lastObservedAt: string; +}; + +export type PostStartKnownWait = "approval" | "input"; + +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.completed` 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; +}; + +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. + */ + 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; +}; + +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; +} + +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); +} + +/** + * Correlate tool lifecycles by `toolCallId`. A completion clears exactly the + * call it identifies, so overlapping tools never clear each other. Rows + * without a `toolCallId` cannot be correlated and are ignored rather than + * guessed at from titles or prose. + */ +function deriveOutstandingTools( + activities: ReadonlyArray, +): ReadonlyArray { + const byToolCallId = new Map(); + for (const activity of [...activities].sort(activityOrder)) { + const payload = payloadRecord(activity); + const toolCallId = trimmed(payload?.toolCallId); + if (toolCallId === undefined) continue; + + if (activity.kind === "tool.started") { + const startedAt = activity.createdAt; + byToolCallId.set(toolCallId, { + toolCallId, + title: trimmed(payload?.title) ?? "Tool", + itemType: trimmed(payload?.itemType) ?? null, + startedAt, + lastObservedAt: startedAt, + }); + continue; + } + + if (activity.kind === "tool.updated") { + const status = trimmed(payload?.status); + if (status !== undefined && TERMINAL_TOOL_STATUSES.has(status)) { + byToolCallId.delete(toolCallId); + continue; + } + const existing = byToolCallId.get(toolCallId); + if (existing === undefined) { + // An update whose start aged out of retention still identifies a live + // call; record it from the first observation we do have. + byToolCallId.set(toolCallId, { + toolCallId, + title: trimmed(payload?.title) ?? "Tool", + itemType: trimmed(payload?.itemType) ?? null, + startedAt: activity.createdAt, + lastObservedAt: activity.createdAt, + }); + continue; + } + byToolCallId.set(toolCallId, { + ...existing, + title: trimmed(payload?.title) ?? existing.title, + lastObservedAt: + maxTimestamp(existing.lastObservedAt, activity.createdAt) ?? activity.createdAt, + }); + continue; + } + + if (activity.kind === "tool.completed") { + byToolCallId.delete(toolCallId); + } + } + return [...byToolCallId.values()]; +} + +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 } = input; + const turnId = + session?.activeTurnId ?? (latestTurn?.state === "running" ? latestTurn.turnId : null); + const active = isSessionActive(session?.status) && turnId !== null; + + const turnStartedAt = + latestTurn?.turnId === turnId + ? parseMs(latestTurn.startedAt) !== null + ? latestTurn.startedAt + : latestTurn.requestedAt + : null; + + if (!active || turnId === null) { + return { + turnId, + active: false, + turnStartedAt, + lastProviderActivityAt: null, + lastToolCompletedAt: null, + outstandingTools: [], + outstandingTool: null, + knownWait: input.knownWait ?? null, + }; + } + + const turnActivities = input.activities.filter((activity) => activity.turnId === turnId); + + let lastProviderActivityAt: string | null = null; + let lastToolCompletedAt: string | null = null; + for (const activity of turnActivities) { + if (!isProviderActivityKind(activity.kind)) continue; + lastProviderActivityAt = maxTimestamp(lastProviderActivityAt, activity.createdAt); + if (activity.kind === "tool.completed") { + lastToolCompletedAt = maxTimestamp(lastToolCompletedAt, activity.createdAt); + } + } + + const outstandingTools = deriveOutstandingTools(turnActivities); + const outstandingTool = + outstandingTools.length === 0 + ? null + : ([...outstandingTools] + .sort((left, right) => compareDateTimeStrings(left.lastObservedAt, right.lastObservedAt)) + .at(-1) ?? null); + + return { + turnId, + active: true, + turnStartedAt, + lastProviderActivityAt, + lastToolCompletedAt, + outstandingTools, + outstandingTool, + knownWait: input.knownWait ?? null, + }; +} + +/** + * 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; + } = {}, +): PostStartActivityObservation { + const thresholdMs = Math.max(0, options.thresholdMs ?? POST_START_SILENCE_THRESHOLD_MS); + const connection = options.connection ?? "live"; + + const lastActivityMs = parseMs(anchors.lastProviderActivityAt); + const lastActivityAgeMs = lastActivityMs === null ? null : Math.max(0, nowMs - lastActivityMs); + const lastToolCompletedMs = parseMs(anchors.lastToolCompletedAt); + const lastToolCompletedAgeMs = + lastToolCompletedMs === null ? null : Math.max(0, nowMs - lastToolCompletedMs); + const outstandingToolAgeMs = + anchors.outstandingTool === null + ? null + : (() => { + const observed = parseMs(anchors.outstandingTool.lastObservedAt); + return observed === null ? null : Math.max(0, nowMs - 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 }; + } + + // 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 }; + } + + const originAt = 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 }; + } + + // A future origin (clock skew) is treated as "just happened", never a + // negative or spurious age. + const quietForMs = Math.max(0, nowMs - originMs); + if (quietForMs < thresholdMs) { + return { ...base, status: "active", quietSinceAt: null, quietForMs, episodeKey: null }; + } + + // Keep the anchor's original representation (with its original offset) so + // the episode key is stable across ticks. + const quietSinceAt = originAt; + return { + ...base, + status: "quiet", + quietSinceAt, + quietForMs, + episodeKey: `${anchors.turnId ?? "none"}:${quietSinceAt}`, + }; +} From 9a90162791e0c514c5256858fa2f376db54284e6 Mon Sep 17 00:00:00 2001 From: "STALL:S1" Date: Fri, 25 Sep 2026 18:51:00 -0400 Subject: [PATCH 02/10] test(web): cover post-start silence notice transitions --- .../chat/PostStartActivityNotice.test.tsx | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 apps/web/src/components/chat/PostStartActivityNotice.test.tsx 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..c5089eed1794 --- /dev/null +++ b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx @@ -0,0 +1,149 @@ +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"; + +const state = vi.hoisted(() => ({ + inApp: true, + add: vi.fn(() => "toast-1"), + close: vi.fn(), +})); + +vi.mock("../../hooks/useSettings", () => ({ + useClientSettings: (select: (settings: { inAppNotificationsEnabled: boolean }) => unknown) => + select({ inAppNotificationsEnabled: state.inApp }), +})); +vi.mock("../ui/toast", () => ({ + toastManager: { add: state.add, close: state.close }, +})); + +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, + ...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", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + }); + state.inApp = true; + state.add.mockClear(); + state.close.mockClear(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("stays silent while activity is recent, then warns exactly at the threshold", () => { + vi.setSystemTime(T0_MS + THRESHOLD_MS - 1_000); + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(); + }); + 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.", + ); + expect(state.add).toHaveBeenCalledTimes(1); + + // Ticking further in the same episode does not re-notify. + act(() => { + vi.advanceTimersByTime(3_000); + }); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("names an outstanding tool and clears when activity resumes", () => { + vi.setSystemTime(T0_MS + THRESHOLD_MS); + const quietAnchors = anchors({ + outstandingTool: { + toolCallId: "call-1", + title: "npm test", + itemType: "command_execution", + startedAt: T0, + lastObservedAt: T0, + }, + outstandingTools: [ + { + toolCallId: "call-1", + title: "npm test", + itemType: "command_execution", + startedAt: T0, + lastObservedAt: T0, + }, + ], + }); + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(); + }); + expect(renderedText(renderer)).toContain("No activity from npm test"); + + // A resumed provider event moves the anchor and clears the warning. + const resumedAt = "2026-01-01T00:05:00.000Z"; + act(() => { + renderer.update( + , + ); + }); + expect(renderedText(renderer)).not.toContain("No activity from npm test"); + expect(state.close).toHaveBeenCalledWith("toast-1"); + }); + + it("shows uncertainty instead of a stop when disconnected", () => { + vi.setSystemTime(T0_MS + 30 * 60_000); + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(); + }); + expect(renderedText(renderer)).toContain("its state is unknown"); + expect(state.add).not.toHaveBeenCalled(); + }); +}); From 72ed19d16bbc6065b57d510c6fd56c45864de25a Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sat, 26 Sep 2026 19:05:53 -0400 Subject: [PATCH 03/10] fix(web): warn on provider silence through server-observed progress R1-R7 for the post-start visibility repair: - observe assistant/reasoning text and tool heartbeats on the server clock (ThreadPostStartActivityService) so progress advances even when provider timestamps stay pinned to a part or tool start; usage-only metadata does not count and a restart manufactures nothing - cover the real pending-start shape and stop a lagging session from keeping an ended turn's warning alive - correlate tools by toolCallId or the toolUseId alias, advance on progress, and treat a terminal tool.updated as a real completion - keep connection-only changes in the timeline row comparison - represent future/skewed origins as honest uncertainty and canonicalize episode identity - move silence notification into the environment-scoped coordinator with per-episode dedup, disconnect/reconnect and preference handling - expose last activity, last completion and known-tool age in the compact inline status, not only in warnings --- .../OrchestrationEngineHarness.integration.ts | 2 + .../Layers/CheckpointReactor.test.ts | 3 + .../Layers/OrchestrationEngine.test.ts | 8 + .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 7 + .../Layers/ProjectionSnapshotQuery.ts | 11 + .../Layers/ProviderCommandReactor.test.ts | 3 + .../Layers/ProviderRuntimeIngestion.test.ts | 43 +++ .../Layers/ProviderRuntimeIngestion.ts | 24 ++ .../ThreadPostStartActivity.test.ts | 78 +++++ .../orchestration/ThreadPostStartActivity.ts | 185 ++++++++++++ apps/server/src/orchestration/runtimeLayer.ts | 2 + .../src/project/AgentSessionImporter.test.ts | 2 + apps/web/src/components/ChatView.tsx | 9 + ...readNotificationCoordinator.badge.test.tsx | 9 +- .../ThreadNotificationCoordinator.test.tsx | 79 ++++- .../ThreadNotificationCoordinator.tsx | 111 ++++++- .../chat/MessagesTimeline.logic.test.ts | 35 +++ .../components/chat/MessagesTimeline.logic.ts | 4 +- .../chat/PostStartActivityNotice.test.tsx | 84 ++---- .../chat/PostStartActivityNotice.tsx | 123 ++++---- packages/contracts/src/orchestration.ts | 29 ++ packages/shared/src/postStartActivity.test.ts | 117 +++++++- packages/shared/src/postStartActivity.ts | 277 +++++++++++++----- 24 files changed, 1046 insertions(+), 201 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadPostStartActivity.test.ts create mode 100644 apps/server/src/orchestration/ThreadPostStartActivity.ts 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..351532cbd13d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -55,6 +55,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,6 +494,7 @@ 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; @@ -2753,6 +2755,9 @@ pending_approval_requests AS ( row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + postStartActivity: threadPostStartActivity.getThreadPostStartActivity( + row.threadId, + ), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2916,6 +2921,9 @@ pending_approval_requests AS ( row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + postStartActivity: threadPostStartActivity.getThreadPostStartActivity( + row.threadId, + ), })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -3272,6 +3280,9 @@ pending_approval_requests AS ( threadRow.value.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), + postStartActivity: threadPostStartActivity.getThreadPostStartActivity( + threadRow.value.threadId, + ), } 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..01cfffe8ff03 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -58,6 +58,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 +329,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)), @@ -1382,6 +1384,47 @@ describe("ProviderRuntimeIngestion", () => { expect(await harness.readModel()).toEqual(initial); }); + 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("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0db70e491235..12ecba5a427a 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; @@ -2579,6 +2581,23 @@ const make = Effect.gen(function* () { } const activities = runtimeEventToActivities(activityEvent, taskTitle); + // Post-start visibility observes meaningful provider progress on the + // server clock. Persisted provider timestamps can stay pinned to a part + // or tool start (OpenCode text, running tools), so the shell needs an + // observation that actually advances while work continues. + const observedAt = DateTime.formatIso(yield* DateTime.now); + for (const activity of activities) { + threadPostStartActivity.recordActivity(thread.id, observedAt, activity); + } + if ( + event.type === "content.delta" && + event.payload.delta.length > 0 && + (event.payload.streamKind === "assistant_text" || + event.payload.streamKind === "reasoning_text" || + event.payload.streamKind === "reasoning_summary_text") + ) { + threadPostStartActivity.recordContentProgress(thread.id, observedAt); + } yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => @@ -2592,6 +2611,11 @@ 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. + if (isTerminalTurn || event.type === "session.exited") { + threadPostStartActivity.clearThread(thread.id); + } }); const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; diff --git a/apps/server/src/orchestration/ThreadPostStartActivity.test.ts b/apps/server/src/orchestration/ThreadPostStartActivity.test.ts new file mode 100644 index 000000000000..6997831eee4a --- /dev/null +++ b/apps/server/src/orchestration/ThreadPostStartActivity.test.ts @@ -0,0 +1,78 @@ +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(); + }); +}); diff --git a/apps/server/src/orchestration/ThreadPostStartActivity.ts b/apps/server/src/orchestration/ThreadPostStartActivity.ts new file mode 100644 index 000000000000..aeb094ecf32b --- /dev/null +++ b/apps/server/src/orchestration/ThreadPostStartActivity.ts @@ -0,0 +1,185 @@ +/** + * 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). + * + * 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 = { + readonly lastProviderActivityAt: string | null; + readonly lastToolCompletedAt: string | null; + readonly outstandingTools: ReadonlyArray; +}; + +const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "declined", + "stopped", +]); + +interface ThreadObservationState { + lastProviderActivityAt: string | null; + lastToolCompletedAt: string | null; + readonly tools: Map; +} + +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, + { + /** + * Record one persisted activity row's normalized event. `observedAt` is the + * server clock instant the event was observed, not the provider timestamp. + */ + readonly recordActivity: ( + threadId: string, + observedAt: string, + activity: Pick, + ) => void; + + /** Assistant/reasoning text progress, which has no activity row of its own. */ + readonly recordContentProgress: (threadId: string, observedAt: string) => void; + + /** Turn ended or session died: the observation no longer describes live work. */ + readonly clearThread: (threadId: string) => void; + + readonly getThreadPostStartActivity: (threadId: string) => ThreadPostStartActivity | null; + } +>()("t3/orchestration/ThreadPostStartActivity/ThreadPostStartActivityService") {} + +export function make(): ThreadPostStartActivityService["Service"] { + const stateByThreadId = new Map(); + + const stateFor = (threadId: string): ThreadObservationState => { + const existing = stateByThreadId.get(threadId); + if (existing) return existing; + const created: ThreadObservationState = { + lastProviderActivityAt: null, + lastToolCompletedAt: null, + tools: new Map(), + }; + stateByThreadId.set(threadId, created); + return created; + }; + + return { + recordActivity: (threadId, observedAt, activity) => { + if (!isMeaningfulProviderActivity({ kind: activity.kind, payload: activity.payload })) { + return; + } + const state = stateFor(threadId); + 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") { + 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.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.lastToolCompletedAt = maxTimestamp(state.lastToolCompletedAt, observedAt); + 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) => { + const state = stateFor(threadId); + state.lastProviderActivityAt = maxTimestamp(state.lastProviderActivityAt, observedAt); + }, + + clearThread: (threadId) => { + stateByThreadId.delete(threadId); + }, + + getThreadPostStartActivity: (threadId) => { + const state = stateByThreadId.get(threadId); + if (!state) return null; + return { + lastProviderActivityAt: state.lastProviderActivityAt, + lastToolCompletedAt: state.lastToolCompletedAt, + outstandingTools: [...state.tools.values()], + }; + }, + }; +} + +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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6510015d3d7..f52660d160bf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2112,6 +2112,10 @@ export default function ChatView(props: ChatViewProps) { (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. const postStartActivityAnchors = useMemo(() => { if (!activeThread) return null; const knownWait: PostStartKnownWait | null = activeThreadShell?.hasPendingApprovals @@ -2119,17 +2123,22 @@ export default function ChatView(props: ChatViewProps) { : activeThreadShell?.hasPendingUserInput ? "input" : null; + const live = activeThreadShell?.postStartActivity ?? null; return derivePostStartActivityAnchors({ activities: activeThread.activities ?? [], latestTurn: activeLatestTurn, session: activeThread.session ?? null, knownWait, + pendingStartedAt: activeThreadShell?.latestUserMessageAt ?? null, + live, }); }, [ activeThread, activeLatestTurn, 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 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..71195ee1e94c 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -17,6 +17,12 @@ const state = vi.hoisted(() => ({ approval: false, sessionError: false, turnError: false, + sessionRunning: false, + postStartActivity: null as null | { + lastProviderActivityAt: string | null; + lastToolCompletedAt: string | null; + outstandingTools: []; + }, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => "toast-1", @@ -40,7 +46,13 @@ vi.mock("@effect/atom-react", () => ({ archivedAt: state.archivedAt, hasPendingUserInput: state.input, hasPendingApprovals: state.approval, - session: state.sessionError ? { status: "error" } : null, + 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", @@ -109,9 +121,17 @@ beforeEach(() => { approval: false, sessionError: false, turnError: false, + sessionRunning: false, + postStartActivity: null, }); 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; @@ -251,4 +271,59 @@ describe("thread notifications", () => { silent: true, }); }); + + it("warns once per silence episode for an unattended running thread", async () => { + state.sessionRunning = true; + state.postStartActivity = { + lastProviderActivityAt: "2020-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); + + // Provider progress resumes: the episode ends and the toast closes. + state.postStartActivity = { + lastProviderActivityAt: "2999-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.close).toHaveBeenCalledWith("toast-1"); + }); + + it("closes the silence toast when in-app notifications are disabled", async () => { + state.sessionRunning = true; + state.postStartActivity = { + lastProviderActivityAt: "2020-01-01T00:00:00.000Z", + lastToolCompletedAt: null, + outstandingTools: [], + }; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + + state.inApp = false; + await render(); + expect(state.close).toHaveBeenCalledWith("toast-1"); + 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(); + expect(state.add).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 1401965e90c7..9d8fedc82ed4 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -5,10 +5,15 @@ 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"; @@ -106,6 +111,20 @@ function EnvironmentNotifications({ const previous = useRef( new Map(), ); + // Post-start silence episodes already surfaced as an in-app toast. Keyed by + // environment+thread+episode so a resumed turn notifies again while a tick, + // remount or reconnect cannot replay the same episode. + const quietToastIds = useRef(new Map()); + // Episodes already surfaced. Kept separately from the open toast map so a + // disconnect, reconnect or preference change can close the toast without + // losing the "already notified" memory and replaying it. + const notifiedEpisodes = useRef(new Set()); + 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 +252,95 @@ function EnvironmentNotifications({ shell, ]); + // Post-start silence: an unattended running thread whose provider has gone + // quiet past the conservative threshold raises one in-app toast per episode. + // The environment shell already carries the server-observed activity, so + // this warns about threads the user is not viewing, follows the existing + // in-app preference and navigation convention, and closes as the episode + // ends or notifications are turned off. + useEffect(() => { + // A quiet provider produces no shell updates, so the wall-clock state + // drives re-evaluation of the threshold. + const closeToasts = () => { + for (const toastId of quietToastIds.current.values()) toastManager.close(toastId); + quietToastIds.current.clear(); + }; + if (!inAppNotificationsEnabled || shell.status !== "live" || Option.isNone(shell.snapshot)) { + closeToasts(); + return; + } + const seen = new Set(); + for (const thread of shell.snapshot.value.threads) { + if (thread.archivedAt !== null) continue; + const anchors = derivePostStartActivityAnchors({ + activities: [], + latestTurn: thread.latestTurn, + session: thread.session, + knownWait: thread.hasPendingApprovals + ? "approval" + : thread.hasPendingUserInput + ? "input" + : null, + pendingStartedAt: thread.latestUserMessageAt, + live: thread.postStartActivity ?? null, + }); + const observation = resolvePostStartActivity(anchors, nowMs); + if (observation.status !== "quiet" || observation.episodeKey === null) continue; + const key = `${environmentId}:${thread.id}:${observation.episodeKey}`; + seen.add(key); + if (notifiedEpisodes.current.has(key)) continue; + if ( + document.visibilityState !== "visible" || + !document.hasFocus() || + (activeEnvironmentId === environmentId && activeThreadId === thread.id) + ) { + continue; + } + 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 }, + }); + }, + }, + }); + notifiedEpisodes.current.add(key); + quietToastIds.current.set(key, toastId); + } + for (const key of notifiedEpisodes.current) { + if (seen.has(key)) continue; + const toastId = quietToastIds.current.get(key); + if (toastId !== undefined) { + toastManager.close(toastId); + quietToastIds.current.delete(key); + } + notifiedEpisodes.current.delete(key); + } + }, [ + activeEnvironmentId, + activeThreadId, + environmentId, + inAppNotificationsEnabled, + navigate, + nowMs, + shell, + ]); + + useEffect( + () => () => { + for (const toastId of quietToastIds.current.values()) toastManager.close(toastId); + quietToastIds.current.clear(); + }, + [], + ); + 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 afad70b07851..5043088dde09 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3734,6 +3734,41 @@ 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, + } 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 b5e02d7a317a..08dd6bd6af11 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1625,7 +1625,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; case "post-start-activity": - return a.anchors === (b as typeof a).anchors; + // 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/PostStartActivityNotice.test.tsx b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx index c5089eed1794..33ca3dd8ad9f 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.test.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx @@ -4,20 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import type { PostStartActivityAnchors } from "@t3tools/shared/postStartActivity"; -const state = vi.hoisted(() => ({ - inApp: true, - add: vi.fn(() => "toast-1"), - close: vi.fn(), -})); - -vi.mock("../../hooks/useSettings", () => ({ - useClientSettings: (select: (settings: { inAppNotificationsEnabled: boolean }) => unknown) => - select({ inAppNotificationsEnabled: state.inApp }), -})); -vi.mock("../ui/toast", () => ({ - toastManager: { add: state.add, close: state.close }, -})); - import { PostStartActivityNotice } from "./PostStartActivityNotice"; const T0 = "2026-01-01T00:00:00.000Z"; @@ -65,21 +51,19 @@ describe("PostStartActivityNotice", () => { setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval, }); - state.inApp = true; - state.add.mockClear(); - state.close.mockClear(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("stays silent while activity is recent, then warns exactly at the threshold", () => { + it("stays informative while active, then warns exactly at the threshold", () => { vi.setSystemTime(T0_MS + THRESHOLD_MS - 1_000); let renderer!: ReactTestRenderer; act(() => { renderer = create(); }); + expect(renderedText(renderer)).toContain("Provider active"); expect(renderedText(renderer)).not.toContain("No provider activity observed"); act(() => { @@ -88,53 +72,44 @@ describe("PostStartActivityNotice", () => { expect(renderedText(renderer)).toContain( "No provider activity observed for over 5 minutes; this turn may still be working.", ); - expect(state.add).toHaveBeenCalledTimes(1); + }); - // Ticking further in the same episode does not re-notify. + 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); + let renderer!: ReactTestRenderer; act(() => { - vi.advanceTimersByTime(3_000); + renderer = create( + , + ); }); - expect(state.add).toHaveBeenCalledTimes(1); - }); + expect(renderedText(renderer)).toContain("Working: npm test"); - it("names an outstanding tool and clears when activity resumes", () => { - vi.setSystemTime(T0_MS + THRESHOLD_MS); - const quietAnchors = anchors({ - outstandingTool: { - toolCallId: "call-1", - title: "npm test", - itemType: "command_execution", - startedAt: T0, - lastObservedAt: T0, - }, - outstandingTools: [ - { - toolCallId: "call-1", - title: "npm test", - itemType: "command_execution", - startedAt: T0, - lastObservedAt: T0, - }, - ], - }); - let renderer!: ReactTestRenderer; act(() => { - renderer = create(); + vi.setSystemTime(T0_MS + 8 * 60_000); + vi.advanceTimersByTime(1_000); }); expect(renderedText(renderer)).toContain("No activity from npm test"); + }); - // A resumed provider event moves the anchor and clears the warning. - const resumedAt = "2026-01-01T00:05:00.000Z"; + it("shows a pending decision as an explained wait, not silence", () => { + vi.setSystemTime(T0_MS + 30 * 60_000); + let renderer!: ReactTestRenderer; act(() => { - renderer.update( - , + renderer = create( + , ); }); - expect(renderedText(renderer)).not.toContain("No activity from npm test"); - expect(state.close).toHaveBeenCalledWith("toast-1"); + expect(renderedText(renderer)).toContain("Waiting for your approval"); }); it("shows uncertainty instead of a stop when disconnected", () => { @@ -144,6 +119,5 @@ describe("PostStartActivityNotice", () => { renderer = create(); }); expect(renderedText(renderer)).toContain("its state is unknown"); - expect(state.add).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/chat/PostStartActivityNotice.tsx b/apps/web/src/components/chat/PostStartActivityNotice.tsx index 1990be559ef8..d644d8d74ab2 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from "react"; -import { ClockIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ClockIcon, LoaderCircleIcon } from "lucide-react"; import type { PostStartActivityAnchors, PostStartConnectionState, @@ -10,13 +10,6 @@ import { } from "@t3tools/shared/postStartActivity"; import { formatDuration } from "../../session-logic"; -import { useClientSettings } from "../../hooks/useSettings"; -import { toastManager } from "../ui/toast"; - -// One notification per silence episode, keyed by the episode's stable -// identity. A resumed turn produces a new key, so silence that recurs later -// can notify again; a tick or a remount cannot replay the same episode. -const notifiedEpisodeToastIds = new Map(); function formatThresholdLabel(thresholdMs: number): string { const minutes = Math.round(thresholdMs / 60_000); @@ -29,15 +22,19 @@ function relativeAge(ageMs: number | null): string { } /** - * Inline post-start status for a running turn. + * Compact inline post-start status for a running turn. * - * Renders nothing while the turn is producing recent progress. Once provider - * activity goes quiet past the threshold it names what is known: the - * outstanding tool (with its own age), or unexplained silence, or a - * disconnected environment we cannot observe. It never says the turn failed. + * 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 warning appears exactly when - * the threshold is crossed without re-rendering the surrounding list. + * 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, @@ -46,11 +43,7 @@ export function PostStartActivityNotice({ anchors: PostStartActivityAnchors; connection: PostStartConnectionState; }) { - const inAppNotificationsEnabled = useClientSettings( - (settings) => settings.inAppNotificationsEnabled, - ); const [nowMs, setNowMs] = useState(() => Date.now()); - const lastNotifiedEpisodeRef = useRef(null); useEffect(() => { const id = window.setInterval(() => setNowMs(Date.now()), 1_000); @@ -58,47 +51,9 @@ export function PostStartActivityNotice({ }, []); const observation = resolvePostStartActivity(anchors, nowMs, { connection }); - const { status, episodeKey } = observation; - - useEffect(() => { - const previousEpisode = lastNotifiedEpisodeRef.current; - if (status !== "quiet") { - if (previousEpisode !== null) { - const toastId = notifiedEpisodeToastIds.get(previousEpisode); - if (toastId !== undefined) { - toastManager.close(toastId); - notifiedEpisodeToastIds.delete(previousEpisode); - } - } - lastNotifiedEpisodeRef.current = null; - return; - } - lastNotifiedEpisodeRef.current = episodeKey; - if (!inAppNotificationsEnabled || episodeKey === null) return; - if (notifiedEpisodeToastIds.has(episodeKey)) return; - const toastId = toastManager.add({ - type: "warning", - title: "No recent provider activity", - description: "This turn may still be working. Open the thread to review its last activity.", - data: { hideCopyButton: true, leadingIcon: }, - }); - notifiedEpisodeToastIds.set(episodeKey, toastId); - }, [episodeKey, inAppNotificationsEnabled, status]); + const { status } = observation; - useEffect( - () => () => { - const episode = lastNotifiedEpisodeRef.current; - if (episode === null) return; - const toastId = notifiedEpisodeToastIds.get(episode); - if (toastId !== undefined) { - toastManager.close(toastId); - notifiedEpisodeToastIds.delete(episode); - } - }, - [], - ); - - if (status !== "quiet" && status !== "unknown") { + if (status === "inactive") { return null; } @@ -110,26 +65,52 @@ export function PostStartActivityNotice({ observation.lastToolCompletedAt === null ? "no tool completion observed" : `last tool completed ${relativeAge(observation.lastToolCompletedAgeMs)}`; + const toolDetail = + observation.outstandingTool === null + ? null + : `${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 mainLabel = - status === "unknown" - ? "Can't observe this turn's provider right now; its state is unknown." - : 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.`; + const isWarning = status === "quiet" || status === "unknown"; return (
- + {isWarning ? ( + + ) : ( + + )}
{mainLabel}
{lastActivityDetail} · {completionDetail} + {status !== "active" && toolDetail !== null ? ` · ${toolDetail}` : ""}
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3e323e4964d5..3719784bbce6 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -670,6 +670,32 @@ 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), +}); +export type OrchestrationPostStartActivity = typeof OrchestrationPostStartActivity.Type; + const OrchestrationLatestTurnState = Schema.Literals([ "running", "interrupted", @@ -935,6 +961,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/src/postStartActivity.test.ts b/packages/shared/src/postStartActivity.test.ts index ffda7407b70f..0b663b7df3b3 100644 --- a/packages/shared/src/postStartActivity.test.ts +++ b/packages/shared/src/postStartActivity.test.ts @@ -180,6 +180,29 @@ describe("derivePostStartActivityAnchors", () => { ], }); 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", () => { @@ -212,6 +235,70 @@ describe("derivePostStartActivityAnchors", () => { 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", () => { @@ -228,7 +315,7 @@ describe("resolvePostStartActivity", () => { 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:${T0}`); + expect(atThreshold.episodeKey).toBe(`turn-1:${Date.parse(T0)}`); }); it("carries the outstanding tool identity and its own age", () => { @@ -315,8 +402,19 @@ describe("resolvePostStartActivity", () => { const future = anchorsFor({ activities: [turnTool("a", T(10 * MIN), "tool.started")], }); - // A future (clock-skewed) activity must not produce a negative age. - expect(resolvePostStartActivity(future, Date.parse(T(3 * MIN))).status).toBe("active"); + // 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: [], @@ -354,6 +452,19 @@ describe("resolvePostStartActivity", () => { ).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({ diff --git a/packages/shared/src/postStartActivity.ts b/packages/shared/src/postStartActivity.ts index eac1a1bd0d9f..154812e6c856 100644 --- a/packages/shared/src/postStartActivity.ts +++ b/packages/shared/src/postStartActivity.ts @@ -12,16 +12,23 @@ * 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` used to correlate - * overlapping tool calls. + * `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`), checkpoints, and thread/project - * scaffolding. Transport heartbeats, reconnects and UI renders are never - * activities, so they cannot masquerade as progress. + * 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 */ @@ -42,18 +49,36 @@ import { compareDateTimeStrings } from "./dateTime.ts"; */ 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` time for this call; the age signal when it stalls. */ + /** 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; +}; + export type PostStartActivityAnchors = { /** The turn being observed, or null when no turn is active. */ readonly turnId: string | null; @@ -66,7 +91,7 @@ export type PostStartActivityAnchors = { readonly turnStartedAt: string | null; /** Last provider-originated activity in this turn, or null. */ readonly lastProviderActivityAt: string | null; - /** Last real `tool.completed` in this turn, or null. */ + /** Last real tool completion in this turn, or null. */ readonly lastToolCompletedAt: string | null; /** Outstanding tool calls in start order. */ readonly outstandingTools: ReadonlyArray; @@ -91,7 +116,8 @@ export type PostStartActivityObservation = { 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. + * 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; }; @@ -107,6 +133,14 @@ export type DerivePostStartActivityInput = { 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; }; function parseMs(value: string | null | undefined): number | null { @@ -161,6 +195,29 @@ function trimmed(value: unknown): string | undefined { 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, @@ -179,66 +236,110 @@ function activityOrder( return left.id.localeCompare(right.id); } +type OutstandingToolsDerivation = { + readonly tools: ReadonlyArray; + readonly lastCompletedAt: string | null; +}; + /** - * Correlate tool lifecycles by `toolCallId`. A completion clears exactly the - * call it identifies, so overlapping tools never clear each other. Rows - * without a `toolCallId` cannot be correlated and are ignored rather than - * guessed at from titles or prose. + * 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. + * `tool.progress` heartbeats advance the matching call's observation age + * without changing its identity. */ function deriveOutstandingTools( activities: ReadonlyArray, -): ReadonlyArray { - const byToolCallId = new Map(); +): OutstandingToolsDerivation { + const byKey = new Map(); + let lastCompletedAt: string | null = null; for (const activity of [...activities].sort(activityOrder)) { const payload = payloadRecord(activity); - const toolCallId = trimmed(payload?.toolCallId); - if (toolCallId === undefined) continue; + const key = toolCorrelationKey(payload); + if (key === undefined) continue; if (activity.kind === "tool.started") { - const startedAt = activity.createdAt; - byToolCallId.set(toolCallId, { - toolCallId, - title: trimmed(payload?.title) ?? "Tool", + byKey.set(key, { + toolCallId: key, + title: toolTitle(payload) ?? "Tool", itemType: trimmed(payload?.itemType) ?? null, - startedAt, - lastObservedAt: startedAt, + startedAt: activity.createdAt, + lastObservedAt: activity.createdAt, }); continue; } - if (activity.kind === "tool.updated") { + if (activity.kind === "tool.completed") { + byKey.delete(key); + lastCompletedAt = maxTimestamp(lastCompletedAt, activity.createdAt); + continue; + } + + if (activity.kind === "tool.updated" || activity.kind === "tool.progress") { const status = trimmed(payload?.status); - if (status !== undefined && TERMINAL_TOOL_STATUSES.has(status)) { - byToolCallId.delete(toolCallId); + if ( + activity.kind === "tool.updated" && + status !== undefined && + TERMINAL_TOOL_STATUSES.has(status) + ) { + byKey.delete(key); + lastCompletedAt = maxTimestamp(lastCompletedAt, activity.createdAt); continue; } - const existing = byToolCallId.get(toolCallId); + const existing = byKey.get(key); if (existing === undefined) { - // An update whose start aged out of retention still identifies a live - // call; record it from the first observation we do have. - byToolCallId.set(toolCallId, { - toolCallId, - title: trimmed(payload?.title) ?? "Tool", + // 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; } - byToolCallId.set(toolCallId, { + byKey.set(key, { ...existing, - title: trimmed(payload?.title) ?? existing.title, + title: toolTitle(payload) ?? existing.title, lastObservedAt: maxTimestamp(existing.lastObservedAt, activity.createdAt) ?? activity.createdAt, }); - continue; } + } + return { tools: [...byKey.values()], lastCompletedAt }; +} - if (activity.kind === "tool.completed") { - byToolCallId.delete(toolCallId); +/** Merge persisted and live tool observations by identity, keeping the latest age. */ +function mergeOutstandingTools( + persisted: ReadonlyArray, + live: ReadonlyArray, +): ReadonlyArray { + const byKey = new Map(); + for (const tool of [...persisted, ...live]) { + const existing = byKey.get(tool.toolCallId); + if (existing === undefined) { + byKey.set(tool.toolCallId, 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 [...byToolCallId.values()]; + 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 { @@ -253,19 +354,41 @@ function isSessionActive(status: string | undefined): boolean { export function derivePostStartActivityAnchors( input: DerivePostStartActivityInput, ): PostStartActivityAnchors { - const { latestTurn, session } = input; - const turnId = - session?.activeTurnId ?? (latestTurn?.state === "running" ? latestTurn.turnId : null); - const active = isSessionActive(session?.status) && turnId !== null; + 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 + latestTurn?.turnId === turnId && turnId !== null ? parseMs(latestTurn.startedAt) !== null ? latestTurn.startedAt : latestTurn.requestedAt - : null; + : pendingStart + ? pendingStartedAt + : null; - if (!active || turnId === null) { + if (!active) { return { turnId, active: false, @@ -278,34 +401,29 @@ export function derivePostStartActivityAnchors( }; } - const turnActivities = input.activities.filter((activity) => activity.turnId === turnId); + const turnActivities = + turnId === null ? [] : input.activities.filter((activity) => activity.turnId === turnId); let lastProviderActivityAt: string | null = null; - let lastToolCompletedAt: string | null = null; for (const activity of turnActivities) { - if (!isProviderActivityKind(activity.kind)) continue; + if (!isMeaningfulProviderActivity(activity)) continue; lastProviderActivityAt = maxTimestamp(lastProviderActivityAt, activity.createdAt); - if (activity.kind === "tool.completed") { - lastToolCompletedAt = maxTimestamp(lastToolCompletedAt, activity.createdAt); - } } - const outstandingTools = deriveOutstandingTools(turnActivities); - const outstandingTool = - outstandingTools.length === 0 - ? null - : ([...outstandingTools] - .sort((left, right) => compareDateTimeStrings(left.lastObservedAt, right.lastObservedAt)) - .at(-1) ?? null); + const derived = deriveOutstandingTools(turnActivities); + const outstandingTools = mergeOutstandingTools(derived.tools, live?.outstandingTools ?? []); return { turnId, active: true, turnStartedAt, - lastProviderActivityAt, - lastToolCompletedAt, + lastProviderActivityAt: maxTimestamp( + lastProviderActivityAt, + live?.lastProviderActivityAt ?? null, + ), + lastToolCompletedAt: maxTimestamp(derived.lastCompletedAt, live?.lastToolCompletedAt ?? null), outstandingTools, - outstandingTool, + outstandingTool: latestOutstandingTool(outstandingTools), knownWait: input.knownWait ?? null, }; } @@ -320,9 +438,14 @@ export function resolvePostStartActivity( options: { readonly connection?: PostStartConnectionState; readonly thresholdMs?: number; + readonly futureToleranceMs?: 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"; const lastActivityMs = parseMs(anchors.lastProviderActivityAt); @@ -370,21 +493,41 @@ export function resolvePostStartActivity( return { ...base, status: "unknown", quietSinceAt: null, quietForMs: 0, episodeKey: null }; } - // A future origin (clock skew) is treated as "just happened", never a - // negative or spurious age. + // 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 - nowMs > futureToleranceMs) { + return { + ...base, + lastProviderActivityAgeMs: + lastActivityMs !== null && lastActivityMs > nowMs ? null : lastActivityAgeMs, + lastToolCompletedAgeMs: + lastToolCompletedMs !== null && lastToolCompletedMs > nowMs ? null : lastToolCompletedAgeMs, + outstandingToolAgeMs: + anchors.outstandingTool !== null && + (parseMs(anchors.outstandingTool.lastObservedAt) ?? 0) > nowMs + ? null + : outstandingToolAgeMs, + status: "unknown", + quietSinceAt: null, + quietForMs: 0, + episodeKey: null, + }; + } + const quietForMs = Math.max(0, nowMs - originMs); if (quietForMs < thresholdMs) { return { ...base, status: "active", quietSinceAt: null, quietForMs, episodeKey: null }; } - // Keep the anchor's original representation (with its original offset) so - // the episode key is stable across ticks. - const quietSinceAt = originAt; + // 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, + quietSinceAt: originAt, quietForMs, - episodeKey: `${anchors.turnId ?? "none"}:${quietSinceAt}`, + episodeKey: `${anchors.turnId ?? "none"}:${originMs}`, }; } From 2c878fc6d0ea292cb53319b26ea05095e3cb0cf7 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sat, 26 Sep 2026 22:39:25 -0400 Subject: [PATCH 04/10] fix(web): deliver and scope post-start observation to subscribed clients Buffered turn-mode content and parent tool heartbeats advanced the server's in-memory observation without dispatching a domain event, so an already-subscribed shell could falsely warn while generation continued. Nudge the existing coalesced activity/shell delivery path (bounded and user-invisible) and observe canonical parent heartbeats directly. Scope live observation to the current turn so a superseding turn resets the record and late old-turn traffic cannot refresh or clear it. Make tool completion terminal per id so persisted/live reconciliation cannot resurrect a finished call in either direction. Measure elapsed time against the server observation basis (with a client/server clock offset) instead of maxing provider and server clocks, and stop claiming 'no activity' when a timestamp is known but its age is not. Complete the existing notification behavior: hydration baseline, episode memory that survives a preference remount, and desktop/sound modes. Model: deepseek/deepseek-v4.1-flash via OpenCode (T3 Code). --- apps/mobile/src/lib/threadActivity.ts | 3 + .../Layers/ProjectionSnapshotQuery.ts | 24 ++- .../Layers/ProviderRuntimeIngestion.ts | 112 +++++++++-- .../orchestration/ThreadPostStartActivity.ts | 83 ++++++-- apps/web/src/components/ChatView.tsx | 8 + .../ThreadNotificationCoordinator.tsx | 184 +++++++++++++----- .../chat/PostStartActivityNotice.tsx | 14 +- apps/web/src/session-logic.ts | 3 + packages/contracts/src/orchestration.ts | 14 ++ packages/shared/src/postStartActivity.ts | 140 +++++++++++-- 10 files changed, 471 insertions(+), 114 deletions(-) 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/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 351532cbd13d..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"; @@ -498,6 +499,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { 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* ( @@ -2705,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), @@ -2755,9 +2764,7 @@ pending_approval_requests AS ( row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - postStartActivity: threadPostStartActivity.getThreadPostStartActivity( - row.threadId, - ), + postStartActivity: readPostStartActivity(row.threadId, postStartObservedAt), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2873,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), @@ -2921,9 +2929,7 @@ pending_approval_requests AS ( row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - postStartActivity: threadPostStartActivity.getThreadPostStartActivity( - row.threadId, - ), + postStartActivity: readPostStartActivity(row.threadId, postStartObservedAt), })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -3239,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, @@ -3280,9 +3288,7 @@ pending_approval_requests AS ( threadRow.value.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), - postStartActivity: threadPostStartActivity.getThreadPostStartActivity( - threadRow.value.threadId, - ), + postStartActivity: readPostStartActivity(threadRow.value.threadId, postStartObservedAt), } satisfies OrchestrationThreadShell); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 12ecba5a427a..9d22b984f897 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1036,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, @@ -1842,6 +1887,49 @@ 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 isContentProgress = + event.type === "content.delta" && + event.payload.delta.length > 0 && + (event.payload.streamKind === "assistant_text" || + event.payload.streamKind === "reasoning_text" || + event.payload.streamKind === "reasoning_summary_text"); + const isEphemeralToolHeartbeat = + event.type === "tool.progress" && event.payload.toolUseId !== undefined; + if (isContentProgress) { + threadPostStartActivity.recordContentProgress(thread.id, observedAt, observationTurnId); + } + if (isEphemeralToolHeartbeat) { + // Canonical Claude parent-conversation heartbeats carry no taskId and + // are intentionally dropped from persisted activities; observe them + // directly so their liveness does not depend on persistence. + threadPostStartActivity.recordActivity( + thread.id, + observedAt, + { kind: "tool.progress", payload: event.payload }, + observationTurnId, + ); + } + if (isContentProgress || isEphemeralToolHeartbeat) { + yield* nudgePostStartDelivery(thread.id, event, observationTurnId, observedAt); + } + } + if ( event.type === "session.started" || event.type === "session.state.changed" || @@ -2581,22 +2669,10 @@ const make = Effect.gen(function* () { } const activities = runtimeEventToActivities(activityEvent, taskTitle); - // Post-start visibility observes meaningful provider progress on the - // server clock. Persisted provider timestamps can stay pinned to a part - // or tool start (OpenCode text, running tools), so the shell needs an - // observation that actually advances while work continues. - const observedAt = DateTime.formatIso(yield* DateTime.now); + // 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); - } - if ( - event.type === "content.delta" && - event.payload.delta.length > 0 && - (event.payload.streamKind === "assistant_text" || - event.payload.streamKind === "reasoning_text" || - event.payload.streamKind === "reasoning_summary_text") - ) { - threadPostStartActivity.recordContentProgress(thread.id, observedAt); + threadPostStartActivity.recordActivity(thread.id, observedAt, activity, observationTurnId); } yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( @@ -2612,8 +2688,10 @@ 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. - if (isTerminalTurn || event.type === "session.exited") { + // 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" || (isTerminalTurn && shouldApplyThreadLifecycle)) { threadPostStartActivity.clearThread(thread.id); } }); diff --git a/apps/server/src/orchestration/ThreadPostStartActivity.ts b/apps/server/src/orchestration/ThreadPostStartActivity.ts index aeb094ecf32b..75f4b80c40ed 100644 --- a/apps/server/src/orchestration/ThreadPostStartActivity.ts +++ b/apps/server/src/orchestration/ThreadPostStartActivity.ts @@ -10,6 +10,12 @@ * 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 @@ -30,6 +36,7 @@ export type ThreadPostStartActivity = { readonly lastProviderActivityAt: string | null; readonly lastToolCompletedAt: string | null; readonly outstandingTools: ReadonlyArray; + readonly completedToolIds: ReadonlyArray; }; const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ @@ -40,9 +47,12 @@ const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ ]); interface ThreadObservationState { + /** The turn this record describes; null until a turn id is known. */ + turnId: string | null; lastProviderActivityAt: string | null; lastToolCompletedAt: string | null; readonly tools: Map; + readonly completedToolIds: Set; } function parseMs(value: string | null): number | null { @@ -75,18 +85,32 @@ function trimmed(value: unknown): string | 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; + /** * 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) => void; + readonly recordContentProgress: ( + threadId: string, + observedAt: string, + turnId?: string | null, + ) => void; /** Turn ended or session died: the observation no longer describes live work. */ readonly clearThread: (threadId: string) => void; @@ -98,24 +122,49 @@ export class ThreadPostStartActivityService extends Context.Service< export function make(): ThreadPostStartActivityService["Service"] { const stateByThreadId = new Map(); - const stateFor = (threadId: string): ThreadObservationState => { + const emptyState = (turnId: string | null): ThreadObservationState => ({ + turnId, + 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. + */ + const stateForObservation = ( + threadId: string, + turnId: string | null | undefined, + ): ThreadObservationState | null => { const existing = stateByThreadId.get(threadId); - if (existing) return existing; - const created: ThreadObservationState = { - lastProviderActivityAt: null, - lastToolCompletedAt: null, - tools: new Map(), - }; - stateByThreadId.set(threadId, created); - return created; + if (existing === undefined) { + return stateByThreadId.set(threadId, emptyState(turnId ?? null)).get(threadId)!; + } + 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 { - recordActivity: (threadId, observedAt, activity) => { + beginTurn: (threadId, turnId) => { + const existing = stateByThreadId.get(threadId); + if (existing !== undefined && existing.turnId === turnId) return; + stateByThreadId.set(threadId, emptyState(turnId)); + }, + + recordActivity: (threadId, observedAt, activity, turnId) => { if (!isMeaningfulProviderActivity({ kind: activity.kind, payload: activity.payload })) { return; } - const state = stateFor(threadId); + const state = stateForObservation(threadId, turnId); + if (state === null) return; state.lastProviderActivityAt = maxTimestamp(state.lastProviderActivityAt, observedAt); const payload = record(activity.payload); @@ -123,6 +172,7 @@ export function make(): ThreadPostStartActivityService["Service"] { 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", @@ -135,6 +185,7 @@ export function make(): ThreadPostStartActivityService["Service"] { if (activity.kind === "tool.completed") { state.tools.delete(key); + state.completedToolIds.add(key); state.lastToolCompletedAt = maxTimestamp(state.lastToolCompletedAt, observedAt); return; } @@ -147,9 +198,11 @@ export function make(): ThreadPostStartActivityService["Service"] { 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, @@ -161,8 +214,9 @@ export function make(): ThreadPostStartActivityService["Service"] { } }, - recordContentProgress: (threadId, observedAt) => { - const state = stateFor(threadId); + recordContentProgress: (threadId, observedAt, turnId) => { + const state = stateForObservation(threadId, turnId); + if (state === null) return; state.lastProviderActivityAt = maxTimestamp(state.lastProviderActivityAt, observedAt); }, @@ -177,6 +231,7 @@ export function make(): ThreadPostStartActivityService["Service"] { lastProviderActivityAt: state.lastProviderActivityAt, lastToolCompletedAt: state.lastToolCompletedAt, outstandingTools: [...state.tools.values()], + completedToolIds: [...state.completedToolIds], }; }, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f52660d160bf..800adc497eb9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2116,6 +2116,12 @@ export default function ChatView(props: ChatViewProps) { // tool heartbeats whose provider timestamps stay pinned to the start; a // restarted or replayed server supplies none, so stored rows never fake // resumed progress. + // Capture the client instant the observation landed so the age is measured + // against the server clock. Set in an effect (never during render). + const [postStartReceivedAtMs, setPostStartReceivedAtMs] = useState(null); + useEffect(() => { + setPostStartReceivedAtMs(Date.now()); + }, [activeThreadShell?.postStartActivity]); const postStartActivityAnchors = useMemo(() => { if (!activeThread) return null; const knownWait: PostStartKnownWait | null = activeThreadShell?.hasPendingApprovals @@ -2131,6 +2137,7 @@ export default function ChatView(props: ChatViewProps) { knownWait, pendingStartedAt: activeThreadShell?.latestUserMessageAt ?? null, live, + receivedAtMs: postStartReceivedAtMs, }); }, [ activeThread, @@ -2139,6 +2146,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.hasPendingUserInput, activeThreadShell?.latestUserMessageAt, activeThreadShell?.postStartActivity, + postStartReceivedAtMs, ]); // 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 diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 9d8fedc82ed4..2c7635ba5322 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -37,6 +37,16 @@ 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()); + // 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 }); @@ -81,6 +91,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) => ( @@ -88,6 +115,9 @@ export function ThreadNotificationCoordinator() { key={environment.environmentId} environmentId={environment.environmentId} onNotification={onNotification} + notifiedSilenceEpisodes={notifiedSilenceEpisodes} + openSilenceToasts={openSilenceToasts} + hydratedThreads={hydratedThreads} /> )); } @@ -95,9 +125,15 @@ export function ThreadNotificationCoordinator() { function EnvironmentNotifications({ environmentId, onNotification, + notifiedSilenceEpisodes, + openSilenceToasts, + hydratedThreads, }: { environmentId: EnvironmentId; onNotification: (environmentId: EnvironmentId, notification: Notification) => void; + notifiedSilenceEpisodes: React.RefObject>; + openSilenceToasts: React.RefObject>; + hydratedThreads: React.RefObject>; }) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); @@ -111,14 +147,15 @@ function EnvironmentNotifications({ const previous = useRef( new Map(), ); - // Post-start silence episodes already surfaced as an in-app toast. Keyed by - // environment+thread+episode so a resumed turn notifies again while a tick, - // remount or reconnect cannot replay the same episode. - const quietToastIds = useRef(new Map()); - // Episodes already surfaced. Kept separately from the open toast map so a - // disconnect, reconnect or preference change can close the toast without - // losing the "already notified" memory and replaying it. - const notifiedEpisodes = useRef(new Set()); + // 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, and we pair + // it with the client instant the shell landed so ages are measured against + // the server clock instead of assuming the two agree. + const shellRef = useRef(undefined); + const shellReceivedAtRef = useRef(Date.now()); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -253,22 +290,29 @@ function EnvironmentNotifications({ ]); // Post-start silence: an unattended running thread whose provider has gone - // quiet past the conservative threshold raises one in-app toast per episode. - // The environment shell already carries the server-observed activity, so - // this warns about threads the user is not viewing, follows the existing - // in-app preference and navigation convention, and closes as the episode - // ends or notifications are turned off. + // 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(() => { - // A quiet provider produces no shell updates, so the wall-clock state - // drives re-evaluation of the threshold. const closeToasts = () => { - for (const toastId of quietToastIds.current.values()) toastManager.close(toastId); - quietToastIds.current.clear(); + for (const toastId of openSilenceToasts.current.values()) toastManager.close(toastId); + openSilenceToasts.current.clear(); }; - if (!inAppNotificationsEnabled || shell.status !== "live" || Option.isNone(shell.snapshot)) { + if (shell.status !== "live" || Option.isNone(shell.snapshot)) { closeToasts(); return; } + // Turning in-app alerts off closes any open silence toast; the episode stays + // remembered so re-enabling cannot replay it. + if (!inAppNotificationsEnabled) { + closeToasts(); + } + if (shellRef.current !== shell) { + shellRef.current = shell; + shellReceivedAtRef.current = Date.now(); + } + const receivedAtMs = shellReceivedAtRef.current; const seen = new Set(); for (const thread of shell.snapshot.value.threads) { if (thread.archivedAt !== null) continue; @@ -283,64 +327,102 @@ function EnvironmentNotifications({ : null, pendingStartedAt: thread.latestUserMessageAt, live: thread.postStartActivity ?? null, + receivedAtMs, }); const observation = resolvePostStartActivity(anchors, nowMs); + const baselineKey = `${environmentId}:${thread.id}`; if (observation.status !== "quiet" || observation.episodeKey === null) continue; - const key = `${environmentId}:${thread.id}:${observation.episodeKey}`; + const key = `${baselineKey}:${observation.episodeKey}`; seen.add(key); - if (notifiedEpisodes.current.has(key)) continue; - if ( - document.visibilityState !== "visible" || - !document.hasFocus() || - (activeEnvironmentId === environmentId && activeThreadId === thread.id) - ) { + if (!hydratedThreads.current.has(baselineKey)) { + // First sighting: record the current episode as already known so the + // initial snapshot cannot storm the user, matching the existing + // attention/completion hydration baseline. + hydratedThreads.current.add(baselineKey); + notifiedSilenceEpisodes.current.add(key); + continue; + } + if (notifiedSilenceEpisodes.current.has(key)) continue; + + const isViewing = activeEnvironmentId === environmentId && activeThreadId === thread.id; + const isForeground = document.visibilityState === "visible" && document.hasFocus(); + if (isViewing) { + notifiedSilenceEpisodes.current.add(key); continue; } - 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); + if (hasNotificationSound(mode)) { + void playNotificationSound("input", () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + let alerted = false; + 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); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId: thread.id }, }); - }, - }, - }); - notifiedEpisodes.current.add(key); - quietToastIds.current.set(key, toastId); + }); + alerted = true; + } catch { + // Browser exposes Notification but rejects desktop presentation. + } + } + if (alerted) notifiedSilenceEpisodes.current.add(key); } - for (const key of notifiedEpisodes.current) { + for (const key of [...notifiedSilenceEpisodes.current]) { if (seen.has(key)) continue; - const toastId = quietToastIds.current.get(key); + const toastId = openSilenceToasts.current.get(key); if (toastId !== undefined) { toastManager.close(toastId); - quietToastIds.current.delete(key); + openSilenceToasts.current.delete(key); } - notifiedEpisodes.current.delete(key); + notifiedSilenceEpisodes.current.delete(key); } }, [ activeEnvironmentId, activeThreadId, environmentId, inAppNotificationsEnabled, + mode, navigate, nowMs, + onNotification, shell, ]); - useEffect( - () => () => { - for (const toastId of quietToastIds.current.values()) toastManager.close(toastId); - quietToastIds.current.clear(); - }, - [], - ); - return null; } diff --git a/apps/web/src/components/chat/PostStartActivityNotice.tsx b/apps/web/src/components/chat/PostStartActivityNotice.tsx index d644d8d74ab2..68be7fb1bac5 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.tsx @@ -58,17 +58,23 @@ export function PostStartActivityNotice({ } const lastActivityDetail = - observation.lastProviderActivityAgeMs === null + observation.lastProviderActivityAt === null ? "no provider activity observed yet" - : `last provider activity ${relativeAge(observation.lastProviderActivityAgeMs)}`; + : observation.lastProviderActivityAgeMs === null + ? "provider activity observed; age unknown" + : `last provider activity ${relativeAge(observation.lastProviderActivityAgeMs)}`; const completionDetail = observation.lastToolCompletedAt === null ? "no tool completion observed" - : `last tool completed ${relativeAge(observation.lastToolCompletedAgeMs)}`; + : observation.lastToolCompletedAgeMs === null + ? "tool completion observed; age unknown" + : `last tool completed ${relativeAge(observation.lastToolCompletedAgeMs)}`; const toolDetail = observation.outstandingTool === null ? null - : `${observation.outstandingTool.title} observed ${relativeAge(observation.outstandingToolAgeMs)}`; + : observation.outstandingToolAgeMs === null + ? `${observation.outstandingTool.title} observed; age unknown` + : `${observation.outstandingTool.title} observed ${relativeAge(observation.outstandingToolAgeMs)}`; const mainLabel = (() => { switch (status) { 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/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3719784bbce6..5812a106fd05 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -693,6 +693,20 @@ export const OrchestrationPostStartActivity = Schema.Struct({ lastProviderActivityAt: Schema.NullOr(IsoDateTime), lastToolCompletedAt: Schema.NullOr(IsoDateTime), outstandingTools: Schema.Array(OrchestrationOutstandingTool), + /** + * 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; diff --git a/packages/shared/src/postStartActivity.ts b/packages/shared/src/postStartActivity.ts index 154812e6c856..26489e122a92 100644 --- a/packages/shared/src/postStartActivity.ts +++ b/packages/shared/src/postStartActivity.ts @@ -77,6 +77,18 @@ 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; }; export type PostStartActivityAnchors = { @@ -99,6 +111,17 @@ export type PostStartActivityAnchors = { 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; }; export type PostStartActivityStatus = "inactive" | "active" | "quiet" | "waiting" | "unknown"; @@ -141,6 +164,12 @@ export type DerivePostStartActivityInput = { 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; }; function parseMs(value: string | null | undefined): number | null { @@ -239,19 +268,24 @@ function activityOrder( 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. - * `tool.progress` heartbeats advance the matching call's observation age - * without changing its identity. + * 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); @@ -259,6 +293,7 @@ function deriveOutstandingTools( if (key === undefined) continue; if (activity.kind === "tool.started") { + if (completedToolIds.has(key)) continue; byKey.set(key, { toolCallId: key, title: toolTitle(payload) ?? "Tool", @@ -271,6 +306,7 @@ function deriveOutstandingTools( if (activity.kind === "tool.completed") { byKey.delete(key); + completedToolIds.add(key); lastCompletedAt = maxTimestamp(lastCompletedAt, activity.createdAt); continue; } @@ -283,9 +319,11 @@ function deriveOutstandingTools( 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 @@ -307,16 +345,23 @@ function deriveOutstandingTools( }); } } - return { tools: [...byKey.values()], lastCompletedAt }; + return { tools: [...byKey.values()], lastCompletedAt, completedToolIds }; } -/** Merge persisted and live tool observations by identity, keeping the latest age. */ +/** + * 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. + */ function mergeOutstandingTools( persisted: ReadonlyArray, live: ReadonlyArray, + completedToolIds: ReadonlySet, ): 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); @@ -388,6 +433,16 @@ export function derivePostStartActivityAnchors( ? 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. + const observingServerClock = live != null; + const liveObservedAtMs = parseMs(live?.observedAt ?? null); + const receivedAtMs = input.receivedAtMs ?? null; + const observationClockOffsetMs = + liveObservedAtMs !== null && receivedAtMs !== null ? liveObservedAtMs - receivedAtMs : null; + if (!active) { return { turnId, @@ -398,6 +453,8 @@ export function derivePostStartActivityAnchors( outstandingTools: [], outstandingTool: null, knownWait: input.knownWait ?? null, + observingServerClock, + observationClockOffsetMs, }; } @@ -411,20 +468,32 @@ export function derivePostStartActivityAnchors( } const derived = deriveOutstandingTools(turnActivities); - const outstandingTools = mergeOutstandingTools(derived.tools, live?.outstandingTools ?? []); + const completedToolIds = new Set([ + ...derived.completedToolIds, + ...(live?.completedToolIds ?? []), + ]); + const outstandingTools = mergeOutstandingTools( + derived.tools, + live?.outstandingTools ?? [], + completedToolIds, + ); + + // 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 = live?.lastProviderActivityAt ?? null; + const liveLastCompleted = live?.lastToolCompletedAt ?? null; return { turnId, active: true, turnStartedAt, - lastProviderActivityAt: maxTimestamp( - lastProviderActivityAt, - live?.lastProviderActivityAt ?? null, - ), - lastToolCompletedAt: maxTimestamp(derived.lastCompletedAt, live?.lastToolCompletedAt ?? null), + lastProviderActivityAt: liveLastActivity ?? lastProviderActivityAt, + lastToolCompletedAt: liveLastCompleted ?? derived.lastCompletedAt, outstandingTools, outstandingTool: latestOutstandingTool(outstandingTools), knownWait: input.knownWait ?? null, + observingServerClock, + observationClockOffsetMs, }; } @@ -448,17 +517,28 @@ export function resolvePostStartActivity( ); const connection = options.connection ?? "live"; + // 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 ? nowMs : nowMs + anchors.observationClockOffsetMs; + const lastActivityMs = parseMs(anchors.lastProviderActivityAt); - const lastActivityAgeMs = lastActivityMs === null ? null : Math.max(0, nowMs - lastActivityMs); + const lastActivityAgeMs = + lastActivityMs === null ? null : Math.max(0, nowBasisMs - lastActivityMs); const lastToolCompletedMs = parseMs(anchors.lastToolCompletedAt); const lastToolCompletedAgeMs = - lastToolCompletedMs === null ? null : Math.max(0, nowMs - lastToolCompletedMs); + 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, nowMs - observed); + return observed === null ? null : Math.max(0, nowBasisMs - observed); })(); const base = { @@ -480,12 +560,32 @@ export function resolvePostStartActivity( 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 }; } - const originAt = maxTimestamp(anchors.lastProviderActivityAt, anchors.turnStartedAt); + // 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) { @@ -497,16 +597,18 @@ export function resolvePostStartActivity( // 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 - nowMs > futureToleranceMs) { + if (originMs - nowBasisMs > futureToleranceMs) { return { ...base, lastProviderActivityAgeMs: - lastActivityMs !== null && lastActivityMs > nowMs ? null : lastActivityAgeMs, + lastActivityMs !== null && lastActivityMs > nowBasisMs ? null : lastActivityAgeMs, lastToolCompletedAgeMs: - lastToolCompletedMs !== null && lastToolCompletedMs > nowMs ? null : lastToolCompletedAgeMs, + lastToolCompletedMs !== null && lastToolCompletedMs > nowBasisMs + ? null + : lastToolCompletedAgeMs, outstandingToolAgeMs: anchors.outstandingTool !== null && - (parseMs(anchors.outstandingTool.lastObservedAt) ?? 0) > nowMs + (parseMs(anchors.outstandingTool.lastObservedAt) ?? 0) > nowBasisMs ? null : outstandingToolAgeMs, status: "unknown", @@ -516,7 +618,7 @@ export function resolvePostStartActivity( }; } - const quietForMs = Math.max(0, nowMs - originMs); + const quietForMs = Math.max(0, nowBasisMs - originMs); if (quietForMs < thresholdMs) { return { ...base, status: "active", quietSinceAt: null, quietForMs, episodeKey: null }; } From 375fc96e4553ab26e5fe2427d36322092b4f5bc0 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sat, 26 Sep 2026 22:39:29 -0400 Subject: [PATCH 05/10] test(web): cover post-start delivery, turn scope, clock basis and notifications Prove an already-open shell receives advancing observation during buffered content and parent heartbeats, that a superseding turn owns its record, that tool completion cannot be reopened, that elapsed time uses a coherent observation basis across both clock directions, and that silence notifications baseline on hydration and survive a preference remount. Model: deepseek/deepseek-v4.1-flash via OpenCode (T3 Code). --- .../Layers/ProviderRuntimeIngestion.test.ts | 245 +++++++++++++++++- .../ThreadPostStartActivity.test.ts | 86 ++++++ .../ThreadNotificationCoordinator.test.tsx | 79 +++++- .../chat/MessagesTimeline.logic.test.ts | 4 + .../chat/PostStartActivityNotice.test.tsx | 62 +++-- packages/shared/src/postStartActivity.test.ts | 224 ++++++++++++++++ 6 files changed, 673 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 01cfffe8ff03..112489f34072 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, @@ -354,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)); @@ -439,6 +455,7 @@ describe("ProviderRuntimeIngestion", () => { sqlCount: sqlCounter.count, setProviderSession: provider.setSession, drain, + domainEvents, }; } @@ -1425,6 +1442,228 @@ describe("ProviderRuntimeIngestion", () => { ); }); + 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(); + 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("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2760,7 +2999,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/ThreadPostStartActivity.test.ts b/apps/server/src/orchestration/ThreadPostStartActivity.test.ts index 6997831eee4a..58360ae56e7e 100644 --- a/apps/server/src/orchestration/ThreadPostStartActivity.test.ts +++ b/apps/server/src/orchestration/ThreadPostStartActivity.test.ts @@ -75,4 +75,90 @@ describe("ThreadPostStartActivityService", () => { 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/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 71195ee1e94c..93b0a4bf6343 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -272,13 +272,23 @@ describe("thread notifications", () => { }); }); - it("warns once per silence episode for an unattended running thread", async () => { + 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( @@ -289,17 +299,18 @@ describe("thread notifications", () => { await render(); expect(state.add).toHaveBeenCalledTimes(1); - // Provider progress resumes: the episode ends and the toast closes. + // Real resumption: the episode ends and the toast closes. state.postStartActivity = { - lastProviderActivityAt: "2999-01-01T00:00:00.000Z", + lastProviderActivityAt: new Date().toISOString(), lastToolCompletedAt: null, outstandingTools: [], }; await render(); expect(state.close).toHaveBeenCalledWith("toast-1"); + expect(state.add).toHaveBeenCalledTimes(1); }); - it("closes the silence toast when in-app notifications are disabled", async () => { + it("does not replay an episode across a preference remount", async () => { state.sessionRunning = true; state.postStartActivity = { lastProviderActivityAt: "2020-01-01T00:00:00.000Z", @@ -307,11 +318,22 @@ describe("thread notifications", () => { 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); }); @@ -324,6 +346,55 @@ describe("thread notifications", () => { 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); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5043088dde09..2c54503503f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1138,6 +1138,8 @@ describe("deriveMessagesTimelineRows", () => { outstandingTools: [], outstandingTool: null, knownWait: null, + observingServerClock: false, + observationClockOffsetMs: null, } as const; const base = { timelineEntries: [], @@ -3744,6 +3746,8 @@ describe("computeStableMessagesTimelineRows", () => { outstandingTools: [], outstandingTool: null, knownWait: null, + observingServerClock: false, + observationClockOffsetMs: null, } as const; const liveRow: MessagesTimelineRow = { kind: "post-start-activity", diff --git a/apps/web/src/components/chat/PostStartActivityNotice.test.tsx b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx index 33ca3dd8ad9f..e70927b3773b 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.test.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx @@ -20,6 +20,8 @@ function anchors(overrides: Partial = {}): PostStartAc outstandingTools: [], outstandingTool: null, knownWait: null, + observingServerClock: false, + observationClockOffsetMs: null, ...overrides, }; } @@ -44,6 +46,7 @@ function renderedText(renderer: ReactTestRenderer): string { } describe("PostStartActivityNotice", () => { + const renderers: ReactTestRenderer[] = []; beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); @@ -53,16 +56,25 @@ describe("PostStartActivityNotice", () => { }); }); afterEach(() => { + for (const renderer of renderers.splice(0)) { + act(() => renderer.unmount()); + } vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("stays informative while active, then warns exactly at the threshold", () => { - vi.setSystemTime(T0_MS + THRESHOLD_MS - 1_000); + function render(element: Parameters[0]): ReactTestRenderer { let renderer!: ReactTestRenderer; act(() => { - renderer = create(); + 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"); @@ -83,15 +95,12 @@ describe("PostStartActivityNotice", () => { lastObservedAt: T0, }; vi.setSystemTime(T0_MS + 2 * 60_000); - let renderer!: ReactTestRenderer; - act(() => { - renderer = create( - , - ); - }); + const renderer = render( + , + ); expect(renderedText(renderer)).toContain("Working: npm test"); act(() => { @@ -103,21 +112,30 @@ describe("PostStartActivityNotice", () => { it("shows a pending decision as an explained wait, not silence", () => { vi.setSystemTime(T0_MS + 30 * 60_000); - let renderer!: ReactTestRenderer; - act(() => { - renderer = create( - , - ); - }); + 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); - let renderer!: ReactTestRenderer; - act(() => { - renderer = create(); - }); + 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/packages/shared/src/postStartActivity.test.ts b/packages/shared/src/postStartActivity.test.ts index 0b663b7df3b3..e3dc614db02c 100644 --- a/packages/shared/src/postStartActivity.test.ts +++ b/packages/shared/src/postStartActivity.test.ts @@ -481,3 +481,227 @@ describe("resolvePostStartActivity", () => { 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"); + }); +}); From f3457966702cc6b51e02abeeea35d261ac0594aa Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Sat, 26 Sep 2026 23:40:50 -0400 Subject: [PATCH 06/10] fix(server): observe canonical provider output and own post-start activity from the request --- .../Layers/ProviderRuntimeIngestion.ts | 84 +++++++++--- .../orchestration/ThreadPostStartActivity.ts | 77 +++++++++-- .../provider/Layers/CodexSessionRuntime.ts | 3 +- apps/web/src/components/ChatView.tsx | 26 ++-- .../ThreadNotificationCoordinator.tsx | 122 +++++++++++++----- .../chat/PostStartActivityNotice.tsx | 6 +- .../src/state/postStartObservationReceipt.ts | 66 ++++++++++ packages/contracts/src/orchestration.ts | 7 + packages/shared/src/postStartActivity.ts | 90 ++++++++++++- 9 files changed, 409 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/state/postStartObservationReceipt.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 9d22b984f897..925e26236757 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1804,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); @@ -1903,29 +1909,59 @@ const make = Effect.gen(function* () { 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 && - (event.payload.streamKind === "assistant_text" || - event.payload.streamKind === "reasoning_text" || - event.payload.streamKind === "reasoning_summary_text"); + (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; + 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) { - // Canonical Claude parent-conversation heartbeats carry no taskId and - // are intentionally dropped from persisted activities; observe them - // directly so their liveness does not depend on persistence. threadPostStartActivity.recordActivity( thread.id, observedAt, - { kind: "tool.progress", payload: event.payload }, + { + kind: "tool.progress", + payload: { + ...event.payload, + ...(heartbeatToolId !== undefined ? { toolCallId: heartbeatToolId } : {}), + }, + }, observationTurnId, ); } - if (isContentProgress || isEphemeralToolHeartbeat) { + if (isContentProgress || isCanonicalToolOutput || isEphemeralToolHeartbeat) { yield* nudgePostStartDelivery(thread.id, event, observationTurnId, observedAt); } } @@ -2691,12 +2727,24 @@ const make = Effect.gen(function* () { // 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" || (isTerminalTurn && shouldApplyThreadLifecycle)) { + 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.ts b/apps/server/src/orchestration/ThreadPostStartActivity.ts index 75f4b80c40ed..2a7d87842430 100644 --- a/apps/server/src/orchestration/ThreadPostStartActivity.ts +++ b/apps/server/src/orchestration/ThreadPostStartActivity.ts @@ -33,6 +33,8 @@ 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; @@ -47,8 +49,15 @@ const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ ]); interface ThreadObservationState { - /** The turn this record describes; null until a turn id is known. */ + /** 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; @@ -92,6 +101,14 @@ export class ThreadPostStartActivityService extends Context.Service< */ 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. @@ -112,8 +129,14 @@ export class ThreadPostStartActivityService extends Context.Service< turnId?: string | null, ) => void; - /** Turn ended or session died: the observation no longer describes live work. */ - readonly clearThread: (threadId: string) => 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; } @@ -122,8 +145,12 @@ export class ThreadPostStartActivityService extends Context.Service< export function make(): ThreadPostStartActivityService["Service"] { const stateByThreadId = new Map(); - const emptyState = (turnId: string | null): ThreadObservationState => ({ + const emptyState = ( + turnId: string | null, + pendingRequestId: string | null, + ): ThreadObservationState => ({ turnId, + pendingRequestId, lastProviderActivityAt: null, lastToolCompletedAt: null, tools: new Map(), @@ -134,6 +161,9 @@ export function make(): ThreadPostStartActivityService["Service"] { * 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, @@ -141,7 +171,11 @@ export function make(): ThreadPostStartActivityService["Service"] { ): ThreadObservationState | null => { const existing = stateByThreadId.get(threadId); if (existing === undefined) { - return stateByThreadId.set(threadId, emptyState(turnId ?? null)).get(threadId)!; + 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) { @@ -155,8 +189,26 @@ export function make(): ThreadPostStartActivityService["Service"] { return { beginTurn: (threadId, turnId) => { const existing = stateByThreadId.get(threadId); - if (existing !== undefined && existing.turnId === turnId) return; - stateByThreadId.set(threadId, emptyState(turnId)); + 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) => { @@ -220,7 +272,15 @@ export function make(): ThreadPostStartActivityService["Service"] { state.lastProviderActivityAt = maxTimestamp(state.lastProviderActivityAt, observedAt); }, - clearThread: (threadId) => { + 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); }, @@ -228,6 +288,7 @@ export function make(): ThreadPostStartActivityService["Service"] { const state = stateByThreadId.get(threadId); if (!state) return null; return { + turnId: state.turnId, lastProviderActivityAt: state.lastProviderActivityAt, lastToolCompletedAt: state.lastToolCompletedAt, outstandingTools: [...state.tools.values()], 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 800adc497eb9..84f7aa6272a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -340,6 +340,10 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + postStartObservationReceiptKey, + rememberPostStartObservationReceipt, +} from "../state/postStartObservationReceipt"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -2116,12 +2120,9 @@ export default function ChatView(props: ChatViewProps) { // tool heartbeats whose provider timestamps stay pinned to the start; a // restarted or replayed server supplies none, so stored rows never fake // resumed progress. - // Capture the client instant the observation landed so the age is measured - // against the server clock. Set in an effect (never during render). - const [postStartReceivedAtMs, setPostStartReceivedAtMs] = useState(null); - useEffect(() => { - setPostStartReceivedAtMs(Date.now()); - }, [activeThreadShell?.postStartActivity]); + // 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 @@ -2130,6 +2131,13 @@ export default function ChatView(props: ChatViewProps) { ? "input" : null; const live = activeThreadShell?.postStartActivity ?? null; + const observedAt = live?.observedAt ?? null; + const receipt = + observedAt === null || activeThreadEnvironmentId === null || activeThreadId === null + ? undefined + : rememberPostStartObservationReceipt( + postStartObservationReceiptKey(activeThreadEnvironmentId, activeThreadId, observedAt), + ); return derivePostStartActivityAnchors({ activities: activeThread.activities ?? [], latestTurn: activeLatestTurn, @@ -2137,16 +2145,18 @@ export default function ChatView(props: ChatViewProps) { knownWait, pendingStartedAt: activeThreadShell?.latestUserMessageAt ?? null, live, - receivedAtMs: postStartReceivedAtMs, + receivedAtMs: receipt?.wallMs ?? null, + receivedMonotonicMs: receipt?.monotonicMs ?? null, }); }, [ activeThread, activeLatestTurn, + activeThreadEnvironmentId, + activeThreadId, activeThreadShell?.hasPendingApprovals, activeThreadShell?.hasPendingUserInput, activeThreadShell?.latestUserMessageAt, activeThreadShell?.postStartActivity, - postStartReceivedAtMs, ]); // 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 diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 2c7635ba5322..70da0c6efdbd 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -17,6 +17,11 @@ import { import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironments } from "../state/environments"; +import { + monotonicNowMs, + postStartObservationReceiptKey, + rememberPostStartObservationReceipt, +} from "../state/postStartObservationReceipt"; import { environmentShell } from "../state/shell"; import { hasDesktopNotifications, @@ -43,6 +48,10 @@ export function ThreadNotificationCoordinator() { // 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. @@ -52,6 +61,13 @@ export function ThreadNotificationCoordinator() { 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)); @@ -115,8 +131,10 @@ export function ThreadNotificationCoordinator() { key={environment.environmentId} environmentId={environment.environmentId} onNotification={onNotification} + dismissNotification={dismissNotification} notifiedSilenceEpisodes={notifiedSilenceEpisodes} openSilenceToasts={openSilenceToasts} + openSilenceDesktopNotifications={openSilenceDesktopNotifications} hydratedThreads={hydratedThreads} /> )); @@ -125,14 +143,18 @@ 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)); @@ -151,11 +173,10 @@ function EnvironmentNotifications({ // 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, and we pair - // it with the client instant the shell landed so ages are measured against - // the server clock instead of assuming the two agree. - const shellRef = useRef(undefined); - const shellReceivedAtRef = useRef(Date.now()); + // 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(() => { @@ -295,27 +316,48 @@ function EnvironmentNotifications({ // 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 closeToasts = () => { - for (const toastId of openSilenceToasts.current.values()) toastManager.close(toastId); - openSilenceToasts.current.clear(); + 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)) { - closeToasts(); + // 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) { - closeToasts(); - } - if (shellRef.current !== shell) { - shellRef.current = shell; - shellReceivedAtRef.current = Date.now(); + closeEnvironmentToasts(); } - const receivedAtMs = shellReceivedAtRef.current; 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. An unrelated shell update or render reuses the same + // receipt instead of re-dating the observation; the monotonic baseline + // keeps elapsed time honest across browser wall-clock changes. + const receipt = + observedAt === null + ? undefined + : rememberPostStartObservationReceipt( + postStartObservationReceiptKey(environmentId, thread.id, observedAt), + ); const anchors = derivePostStartActivityAnchors({ activities: [], latestTurn: thread.latestTurn, @@ -326,36 +368,48 @@ function EnvironmentNotifications({ ? "input" : null, pendingStartedAt: thread.latestUserMessageAt, - live: thread.postStartActivity ?? null, - receivedAtMs, + live, + receivedAtMs: receipt?.wallMs ?? null, + receivedMonotonicMs: receipt?.monotonicMs ?? null, + }); + const observation = resolvePostStartActivity(anchors, nowMs, { + nowMonotonicMs: monotonicNowMs(), }); - const observation = resolvePostStartActivity(anchors, nowMs); const baselineKey = `${environmentId}:${thread.id}`; - if (observation.status !== "quiet" || observation.episodeKey === null) continue; - const key = `${baselineKey}:${observation.episodeKey}`; - seen.add(key); + // 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)) { - // First sighting: record the current episode as already known so the - // initial snapshot cannot storm the user, matching the existing - // attention/completion hydration baseline. hydratedThreads.current.add(baselineKey); - notifiedSilenceEpisodes.current.add(key); + if (observation.status === "quiet" && observation.episodeKey !== null) { + notifiedSilenceEpisodes.current.add(`${baselineKey}:${observation.episodeKey}`); + } continue; } + if (observation.status !== "quiet" || observation.episodeKey === null) continue; + const key = `${baselineKey}:${observation.episodeKey}`; + seen.add(key); if (notifiedSilenceEpisodes.current.has(key)) continue; - const isViewing = activeEnvironmentId === environmentId && activeThreadId === thread.id; 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; } - if (hasNotificationSound(mode)) { + const soundEnabled = hasNotificationSound(mode); + if (soundEnabled) { void playNotificationSound("input", () => hasNotificationSound(getClientSettings().notificationMode), ); } - let alerted = false; + // 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", @@ -388,6 +442,7 @@ function EnvironmentNotifications({ silent: true, }); onNotification(environmentId, notification); + openSilenceDesktopNotifications.current.set(key, notification.tag); notification.addEventListener("click", () => { notification.close(); window.focus(); @@ -403,18 +458,27 @@ function EnvironmentNotifications({ } 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 (seen.has(key)) continue; + 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, diff --git a/apps/web/src/components/chat/PostStartActivityNotice.tsx b/apps/web/src/components/chat/PostStartActivityNotice.tsx index 68be7fb1bac5..92493284c2da 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.tsx @@ -10,6 +10,7 @@ import { } from "@t3tools/shared/postStartActivity"; import { formatDuration } from "../../session-logic"; +import { monotonicNowMs } from "../../state/postStartObservationReceipt"; function formatThresholdLabel(thresholdMs: number): string { const minutes = Math.round(thresholdMs / 60_000); @@ -50,7 +51,10 @@ export function PostStartActivityNotice({ return () => window.clearInterval(id); }, []); - const observation = resolvePostStartActivity(anchors, nowMs, { connection }); + const observation = resolvePostStartActivity(anchors, nowMs, { + connection, + nowMonotonicMs: monotonicNowMs(), + }); const { status } = observation; if (status === "inactive") { diff --git a/apps/web/src/state/postStartObservationReceipt.ts b/apps/web/src/state/postStartObservationReceipt.ts new file mode 100644 index 000000000000..b7f998f30c24 --- /dev/null +++ b/apps/web/src/state/postStartObservationReceipt.ts @@ -0,0 +1,66 @@ +/** + * 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. + * + * This is a bounded memo keyed by `environmentId:threadId:observedAt`. The + * first time a given observation is seen the receipt is recorded (wall clock + * plus a monotonic baseline); every later sighting — including from another + * component — reuses the original receipt. Elapsed time is then measured from + * the monotonic baseline, so a browser wall-clock change between observations + * cannot fabricate a silence age. Not a clock-sync service: it only remembers + * when bytes actually arrived. + * + * @module postStartObservationReceipt + */ +export type PostStartObservationReceipt = { + readonly wallMs: number; + readonly monotonicMs: number; +}; + +const MAX_RECEIPTS = 512; +const receipts = new Map(); + +export function monotonicNowMs(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} + +export function postStartObservationReceiptKey( + environmentId: string, + threadId: string, + observedAt: string, +): string { + return `${environmentId}:${threadId}:${observedAt}`; +} + +/** + * Return the receipt for an observation, recording it on first sight. `now` + * is injectable for controlled tests; production callers omit it. + */ +export function rememberPostStartObservationReceipt( + key: string, + now: { readonly wallMs?: number; readonly monotonicMs?: number } = {}, +): PostStartObservationReceipt { + const existing = receipts.get(key); + if (existing !== undefined) return existing; + const receipt: PostStartObservationReceipt = { + wallMs: now.wallMs ?? Date.now(), + monotonicMs: now.monotonicMs ?? monotonicNowMs(), + }; + if (receipts.size >= MAX_RECEIPTS) { + const oldest = receipts.keys().next().value; + if (oldest !== undefined) receipts.delete(oldest); + } + receipts.set(key, receipt); + return receipt; +} + +export function resetPostStartObservationReceipts(): void { + receipts.clear(); +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 5812a106fd05..c24d53025dc2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -693,6 +693,13 @@ 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 diff --git a/packages/shared/src/postStartActivity.ts b/packages/shared/src/postStartActivity.ts index 26489e122a92..64500156ef42 100644 --- a/packages/shared/src/postStartActivity.ts +++ b/packages/shared/src/postStartActivity.ts @@ -89,6 +89,13 @@ export type PostStartLiveObservation = { * 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 = { @@ -122,6 +129,19 @@ export type PostStartActivityAnchors = { * 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"; @@ -170,6 +190,12 @@ export type DerivePostStartActivityInput = { * 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 { @@ -353,11 +379,18 @@ function deriveOutstandingTools( * 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]) { @@ -367,6 +400,10 @@ function mergeOutstandingTools( 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)) { @@ -437,9 +474,25 @@ export function derivePostStartActivityAnchors( // 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. - const observingServerClock = live != null; - const liveObservedAtMs = parseMs(live?.observedAt ?? null); + // + // 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; @@ -455,6 +508,8 @@ export function derivePostStartActivityAnchors( knownWait: input.knownWait ?? null, observingServerClock, observationClockOffsetMs, + receivedAtMs, + receivedMonotonicMs, }; } @@ -470,18 +525,19 @@ export function derivePostStartActivityAnchors( const derived = deriveOutstandingTools(turnActivities); const completedToolIds = new Set([ ...derived.completedToolIds, - ...(live?.completedToolIds ?? []), + ...(currentLive?.completedToolIds ?? []), ]); const outstandingTools = mergeOutstandingTools( derived.tools, - live?.outstandingTools ?? [], + 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 = live?.lastProviderActivityAt ?? null; - const liveLastCompleted = live?.lastToolCompletedAt ?? null; + const liveLastActivity = currentLive?.lastProviderActivityAt ?? null; + const liveLastCompleted = currentLive?.lastToolCompletedAt ?? null; return { turnId, @@ -494,6 +550,8 @@ export function derivePostStartActivityAnchors( knownWait: input.knownWait ?? null, observingServerClock, observationClockOffsetMs, + receivedAtMs, + receivedMonotonicMs, }; } @@ -508,6 +566,12 @@ export function resolvePostStartActivity( 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); @@ -517,6 +581,16 @@ export function resolvePostStartActivity( ); 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. @@ -525,7 +599,9 @@ export function resolvePostStartActivity( anchors.observationClockOffsetMs !== null && Math.abs(anchors.observationClockOffsetMs) > futureToleranceMs; const nowBasisMs = - anchors.observationClockOffsetMs === null ? nowMs : nowMs + anchors.observationClockOffsetMs; + anchors.observationClockOffsetMs === null + ? basisNowMs + : basisNowMs + anchors.observationClockOffsetMs; const lastActivityMs = parseMs(anchors.lastProviderActivityAt); const lastActivityAgeMs = From 0d50340b9a5cc09f4f40f201f04f3fbb10106f0c Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Sat, 26 Sep 2026 23:41:09 -0400 Subject: [PATCH 07/10] test(web): cover canonical progress, pending ownership, clock authority and notification isolation --- .../Layers/ProviderRuntimeIngestion.test.ts | 233 ++++++++++++++++- .../Layers/CodexSessionRuntime.test.ts | 24 ++ .../ThreadNotificationCoordinator.test.tsx | 234 +++++++++++++++--- .../chat/MessagesTimeline.logic.test.ts | 4 + .../chat/PostStartActivityNotice.test.tsx | 2 + .../state/postStartObservationReceipt.test.ts | 51 ++++ packages/shared/src/postStartActivity.test.ts | 120 +++++++++ 7 files changed, 628 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/state/postStartObservationReceipt.test.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 112489f34072..a729ed73024d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1378,27 +1378,115 @@ 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(); - expect(await harness.readModel()).toEqual(initial); + + 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 () => { @@ -1664,6 +1752,127 @@ describe("ProviderRuntimeIngestion", () => { 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 traffic, including a delayed completion, arrives while B is + // starting with no provider turn id. + 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" }, + }); + 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(); + // B keeps its own pending record: no A tools and no A activity timestamp, + // so its silence is anchored to its own request time. + 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 () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; 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/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 93b0a4bf6343..773ee399cfb0 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -22,10 +22,14 @@ const state = vi.hoisted(() => ({ lastProviderActivityAt: string | null; lastToolCompletedAt: string | null; outstandingTools: []; + observedAt?: string | null; }, + environments: ["env-1"] as string[], + threadsByEnv: {} 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(), @@ -36,32 +40,31 @@ 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" } - : 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, - }, + useAtomValue: (environmentId: string) => { + if (!state.live) 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, @@ -76,10 +79,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()), @@ -91,6 +96,7 @@ vi.mock("./ui/toast", () => ({ })); import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; +import { resetPostStartObservationReceipts } from "../state/postStartObservationReceipt"; let renderer: ReactTestRenderer | undefined; @@ -106,8 +112,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, @@ -123,6 +160,9 @@ beforeEach(() => { turnError: false, sessionRunning: false, postStartActivity: null, + environments: ["env-1"], + threadsByEnv: {}, + toastCounter: 0, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const stubWindow = new EventTarget() as EventTarget & { @@ -397,4 +437,142 @@ describe("thread notifications", () => { 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" }), + ); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 2c54503503f4..bfc975255a2d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1140,6 +1140,8 @@ describe("deriveMessagesTimelineRows", () => { knownWait: null, observingServerClock: false, observationClockOffsetMs: null, + receivedAtMs: null, + receivedMonotonicMs: null, } as const; const base = { timelineEntries: [], @@ -3748,6 +3750,8 @@ describe("computeStableMessagesTimelineRows", () => { knownWait: null, observingServerClock: false, observationClockOffsetMs: null, + receivedAtMs: null, + receivedMonotonicMs: null, } as const; const liveRow: MessagesTimelineRow = { kind: "post-start-activity", diff --git a/apps/web/src/components/chat/PostStartActivityNotice.test.tsx b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx index e70927b3773b..7d2479da73f8 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.test.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.test.tsx @@ -22,6 +22,8 @@ function anchors(overrides: Partial = {}): PostStartAc knownWait: null, observingServerClock: false, observationClockOffsetMs: null, + receivedAtMs: null, + receivedMonotonicMs: null, ...overrides, }; } diff --git a/apps/web/src/state/postStartObservationReceipt.test.ts b/apps/web/src/state/postStartObservationReceipt.test.ts new file mode 100644 index 000000000000..4b15357a5f45 --- /dev/null +++ b/apps/web/src/state/postStartObservationReceipt.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + monotonicNowMs, + postStartObservationReceiptKey, + rememberPostStartObservationReceipt, + resetPostStartObservationReceipts, +} from "./postStartObservationReceipt"; + +beforeEach(() => { + resetPostStartObservationReceipts(); +}); + +describe("postStartObservationReceipt", () => { + it("reuses the original receipt when the same observation is re-sighted", () => { + const key = postStartObservationReceiptKey("env-1", "thread-a", "observed-1"); + const first = rememberPostStartObservationReceipt(key, { wallMs: 1_000, monotonicMs: 10 }); + // An unrelated shell update, render, navigation or preference remount keeps + // the same observation object; the receipt must not be re-dated. + const later = rememberPostStartObservationReceipt(key, { + wallMs: 9_999_999, + monotonicMs: 8_888_888, + }); + expect(later).toEqual(first); + }); + + it("records a distinct receipt for a genuinely new observation", () => { + const first = rememberPostStartObservationReceipt( + postStartObservationReceiptKey("env-1", "thread-a", "observed-1"), + { wallMs: 1, monotonicMs: 1 }, + ); + const second = rememberPostStartObservationReceipt( + postStartObservationReceiptKey("env-1", "thread-a", "observed-2"), + { wallMs: 2, monotonicMs: 2 }, + ); + expect(second).not.toEqual(first); + expect(second.wallMs).toBe(2); + expect(second.monotonicMs).toBe(2); + }); + + it("scopes receipts by environment and thread", () => { + const a = postStartObservationReceiptKey("env-1", "thread-a", "observed-1"); + const b = postStartObservationReceiptKey("env-2", "thread-a", "observed-1"); + const c = postStartObservationReceiptKey("env-1", "thread-b", "observed-1"); + expect(new Set([a, b, c]).size).toBe(3); + }); + + it("exposes a monotonic clock reading", () => { + expect(Number.isFinite(monotonicNowMs())).toBe(true); + }); +}); diff --git a/packages/shared/src/postStartActivity.test.ts b/packages/shared/src/postStartActivity.test.ts index e3dc614db02c..9e41ad2473e8 100644 --- a/packages/shared/src/postStartActivity.test.ts +++ b/packages/shared/src/postStartActivity.test.ts @@ -704,4 +704,124 @@ describe("observation clock basis", () => { }); 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)); + }); }); From 1b04ce96b3be97e99836f5f836267ede265084e1 Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Mon, 28 Sep 2026 09:11:39 -0400 Subject: [PATCH 08/10] test(web): make pending-ownership regression discriminating and cover terminal/disconnect cleanup --- .../Layers/ProviderRuntimeIngestion.test.ts | 19 ++++- .../ThreadNotificationCoordinator.test.tsx | 73 ++++++++++++++++++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index a729ed73024d..b020be075337 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1833,8 +1833,9 @@ describe("ProviderRuntimeIngestion", () => { (thread) => thread.session?.status === "starting" && thread.session?.activeTurnId === null, ); - // Ended-turn A traffic, including a delayed completion, arrives while B is - // starting with no provider turn id. + // 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", @@ -1854,6 +1855,18 @@ describe("ProviderRuntimeIngestion", () => { 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"), @@ -1866,8 +1879,6 @@ describe("ProviderRuntimeIngestion", () => { await harness.drain(); const shell = 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. expect(shell.postStartActivity?.turnId ?? null).toBeNull(); expect(shell.postStartActivity?.outstandingTools ?? []).toEqual([]); expect(shell.postStartActivity?.lastProviderActivityAt ?? null).toBeNull(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 773ee399cfb0..3de6e92c2c01 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({ }, environments: ["env-1"] as string[], threadsByEnv: {} as Record>>, + envLive: {} as Record, toastCounter: 0, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => @@ -41,7 +42,9 @@ const state = vi.hoisted(() => ({ vi.mock("@effect/atom-react", () => ({ useAtomValue: (environmentId: string) => { - if (!state.live) return { status: "disconnected", snapshot: Option.none() }; + if (!state.live || state.envLive[environmentId] === false) { + return { status: "disconnected", snapshot: Option.none() }; + } const threads = state.threadsByEnv[environmentId] ?? [ { id: "thread-1", @@ -162,6 +165,7 @@ beforeEach(() => { postStartActivity: null, environments: ["env-1"], threadsByEnv: {}, + envLive: {}, toastCounter: 0, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); @@ -575,4 +579,71 @@ describe("thread notifications", () => { 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); + }); }); From 8d5173db677d614f281a00630d2c45123d78c61b Mon Sep 17 00:00:00 2001 From: STALL S6 Date: Mon, 28 Sep 2026 20:38:31 -0400 Subject: [PATCH 09/10] test(web): reproduce first-load hydration suppression and receipt-basis defects Cover the C1 first-load quiet suppression being deleted in the same evaluation, and the C2 receipt being captured at UI consumption rather than when the shell state accepts the observation. The receipt suite now drives the state-acceptance surface plus the shared derivation and the coordinator consumer. --- .../ThreadNotificationCoordinator.test.tsx | 46 +++- packages/client-runtime/package.json | 4 + .../state/postStartObservationReceipt.test.ts | 98 +++++++++ .../src/state/postStartObservationReceipt.ts | 147 +++++++++++++ .../src/state/shell-sync.test.ts | 208 ++++++++++++++++++ 5 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 packages/client-runtime/src/state/postStartObservationReceipt.test.ts create mode 100644 packages/client-runtime/src/state/postStartObservationReceipt.ts diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 3de6e92c2c01..2d1f80537ae7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -99,7 +99,7 @@ vi.mock("./ui/toast", () => ({ })); import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; -import { resetPostStartObservationReceipts } from "../state/postStartObservationReceipt"; +import { resetPostStartObservationReceipts } from "@t3tools/client-runtime/state/post-start-observation-receipt"; let renderer: ReactTestRenderer | undefined; @@ -190,6 +190,7 @@ beforeEach(() => { afterEach(async () => { await act(() => renderer?.unmount()); renderer = undefined; + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -354,6 +355,49 @@ describe("thread notifications", () => { 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 = { 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); + }), + ); }); From 8b7412d6cea10bb45d44a6af0d01fd01ac947db4 Mon Sep 17 00:00:00 2001 From: STALL S6 Date: Mon, 28 Sep 2026 20:39:10 -0400 Subject: [PATCH 10/10] fix(web): keep hydration suppression and record receipt at shell acceptance C1: the first-load quiet baseline now marks its episode as still-current in the same evaluation, so the episode cleanup cannot delete the suppression it just added and re-alert on the next unchanged tick. C2: the receipt basis is recorded by the client shell state when it accepts a snapshot, before the new state is observable, instead of at first UI consumption. Receipts are keyed by environment/thread and hold only the current observation, so unrelated traffic cannot evict or re-date a live observation. The coordinator and ChatView read that basis rather than creating one. --- apps/web/src/components/ChatView.tsx | 11 +--- .../ThreadNotificationCoordinator.tsx | 25 +++---- .../chat/PostStartActivityNotice.tsx | 2 +- .../state/postStartObservationReceipt.test.ts | 51 -------------- .../src/state/postStartObservationReceipt.ts | 66 ------------------- packages/client-runtime/src/state/shell.ts | 8 +++ 6 files changed, 26 insertions(+), 137 deletions(-) delete mode 100644 apps/web/src/state/postStartObservationReceipt.test.ts delete mode 100644 apps/web/src/state/postStartObservationReceipt.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f7aa6272a2..ef7695eb3506 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -340,10 +340,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { - postStartObservationReceiptKey, - rememberPostStartObservationReceipt, -} from "../state/postStartObservationReceipt"; +import { resolvePostStartObservationReceipt } from "@t3tools/client-runtime/state/post-start-observation-receipt"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -2134,10 +2131,8 @@ export default function ChatView(props: ChatViewProps) { const observedAt = live?.observedAt ?? null; const receipt = observedAt === null || activeThreadEnvironmentId === null || activeThreadId === null - ? undefined - : rememberPostStartObservationReceipt( - postStartObservationReceiptKey(activeThreadEnvironmentId, activeThreadId, observedAt), - ); + ? null + : resolvePostStartObservationReceipt(activeThreadEnvironmentId, activeThreadId, observedAt); return derivePostStartActivityAnchors({ activities: activeThread.activities ?? [], latestTurn: activeLatestTurn, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 70da0c6efdbd..cc8006387b58 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -19,9 +19,8 @@ import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironments } from "../state/environments"; import { monotonicNowMs, - postStartObservationReceiptKey, - rememberPostStartObservationReceipt, -} from "../state/postStartObservationReceipt"; + resolvePostStartObservationReceipt, +} from "@t3tools/client-runtime/state/post-start-observation-receipt"; import { environmentShell } from "../state/shell"; import { hasDesktopNotifications, @@ -349,15 +348,14 @@ function EnvironmentNotifications({ const live = thread.postStartActivity ?? null; const observedAt = live?.observedAt ?? null; // Pair each distinct server observation with the client instant it - // actually arrived. An unrelated shell update or render reuses the same - // receipt instead of re-dating the observation; the monotonic baseline - // keeps elapsed time honest across browser wall-clock changes. + // 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 - ? undefined - : rememberPostStartObservationReceipt( - postStartObservationReceiptKey(environmentId, thread.id, observedAt), - ); + ? null + : resolvePostStartObservationReceipt(environmentId, thread.id, observedAt); const anchors = derivePostStartActivityAnchors({ activities: [], latestTurn: thread.latestTurn, @@ -383,7 +381,12 @@ function EnvironmentNotifications({ if (!hydratedThreads.current.has(baselineKey)) { hydratedThreads.current.add(baselineKey); if (observation.status === "quiet" && observation.episodeKey !== null) { - notifiedSilenceEpisodes.current.add(`${baselineKey}:${observation.episodeKey}`); + 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; } diff --git a/apps/web/src/components/chat/PostStartActivityNotice.tsx b/apps/web/src/components/chat/PostStartActivityNotice.tsx index 92493284c2da..53079d4ca35f 100644 --- a/apps/web/src/components/chat/PostStartActivityNotice.tsx +++ b/apps/web/src/components/chat/PostStartActivityNotice.tsx @@ -10,7 +10,7 @@ import { } from "@t3tools/shared/postStartActivity"; import { formatDuration } from "../../session-logic"; -import { monotonicNowMs } from "../../state/postStartObservationReceipt"; +import { monotonicNowMs } from "@t3tools/client-runtime/state/post-start-observation-receipt"; function formatThresholdLabel(thresholdMs: number): string { const minutes = Math.round(thresholdMs / 60_000); diff --git a/apps/web/src/state/postStartObservationReceipt.test.ts b/apps/web/src/state/postStartObservationReceipt.test.ts deleted file mode 100644 index 4b15357a5f45..000000000000 --- a/apps/web/src/state/postStartObservationReceipt.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, describe, expect, it } from "vite-plus/test"; - -import { - monotonicNowMs, - postStartObservationReceiptKey, - rememberPostStartObservationReceipt, - resetPostStartObservationReceipts, -} from "./postStartObservationReceipt"; - -beforeEach(() => { - resetPostStartObservationReceipts(); -}); - -describe("postStartObservationReceipt", () => { - it("reuses the original receipt when the same observation is re-sighted", () => { - const key = postStartObservationReceiptKey("env-1", "thread-a", "observed-1"); - const first = rememberPostStartObservationReceipt(key, { wallMs: 1_000, monotonicMs: 10 }); - // An unrelated shell update, render, navigation or preference remount keeps - // the same observation object; the receipt must not be re-dated. - const later = rememberPostStartObservationReceipt(key, { - wallMs: 9_999_999, - monotonicMs: 8_888_888, - }); - expect(later).toEqual(first); - }); - - it("records a distinct receipt for a genuinely new observation", () => { - const first = rememberPostStartObservationReceipt( - postStartObservationReceiptKey("env-1", "thread-a", "observed-1"), - { wallMs: 1, monotonicMs: 1 }, - ); - const second = rememberPostStartObservationReceipt( - postStartObservationReceiptKey("env-1", "thread-a", "observed-2"), - { wallMs: 2, monotonicMs: 2 }, - ); - expect(second).not.toEqual(first); - expect(second.wallMs).toBe(2); - expect(second.monotonicMs).toBe(2); - }); - - it("scopes receipts by environment and thread", () => { - const a = postStartObservationReceiptKey("env-1", "thread-a", "observed-1"); - const b = postStartObservationReceiptKey("env-2", "thread-a", "observed-1"); - const c = postStartObservationReceiptKey("env-1", "thread-b", "observed-1"); - expect(new Set([a, b, c]).size).toBe(3); - }); - - it("exposes a monotonic clock reading", () => { - expect(Number.isFinite(monotonicNowMs())).toBe(true); - }); -}); diff --git a/apps/web/src/state/postStartObservationReceipt.ts b/apps/web/src/state/postStartObservationReceipt.ts deleted file mode 100644 index b7f998f30c24..000000000000 --- a/apps/web/src/state/postStartObservationReceipt.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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. - * - * This is a bounded memo keyed by `environmentId:threadId:observedAt`. The - * first time a given observation is seen the receipt is recorded (wall clock - * plus a monotonic baseline); every later sighting — including from another - * component — reuses the original receipt. Elapsed time is then measured from - * the monotonic baseline, so a browser wall-clock change between observations - * cannot fabricate a silence age. Not a clock-sync service: it only remembers - * when bytes actually arrived. - * - * @module postStartObservationReceipt - */ -export type PostStartObservationReceipt = { - readonly wallMs: number; - readonly monotonicMs: number; -}; - -const MAX_RECEIPTS = 512; -const receipts = new Map(); - -export function monotonicNowMs(): number { - return typeof performance !== "undefined" && typeof performance.now === "function" - ? performance.now() - : Date.now(); -} - -export function postStartObservationReceiptKey( - environmentId: string, - threadId: string, - observedAt: string, -): string { - return `${environmentId}:${threadId}:${observedAt}`; -} - -/** - * Return the receipt for an observation, recording it on first sight. `now` - * is injectable for controlled tests; production callers omit it. - */ -export function rememberPostStartObservationReceipt( - key: string, - now: { readonly wallMs?: number; readonly monotonicMs?: number } = {}, -): PostStartObservationReceipt { - const existing = receipts.get(key); - if (existing !== undefined) return existing; - const receipt: PostStartObservationReceipt = { - wallMs: now.wallMs ?? Date.now(), - monotonicMs: now.monotonicMs ?? monotonicNowMs(), - }; - if (receipts.size >= MAX_RECEIPTS) { - const oldest = receipts.keys().next().value; - if (oldest !== undefined) receipts.delete(oldest); - } - receipts.set(key, receipt); - return receipt; -} - -export function resetPostStartObservationReceipts(): void { - receipts.clear(); -} 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);