diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 0f18f24923cc..471e6cb745a3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -343,9 +343,7 @@ export function useThreadListActions(): { if (!canSnooze(thread, { now: new Date().toISOString() })) { Dialog.alert( "Could not snooze thread", - thread.hasPendingApprovals || - thread.hasPendingUserInput || - thread.pendingSubagentRequest != null + thread.hasPendingApprovals || thread.pendingSubagentRequest != null ? "This thread is waiting on you. Respond to the pending request before snoozing it." : "This thread is still starting a turn. Try again once it's running.", ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index a67e3a6fc8a9..99184b7f1109 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -12,7 +12,7 @@ import { type ThreadMoveAvailability, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, EnvironmentId, @@ -21,6 +21,7 @@ import { ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -33,6 +34,7 @@ import { buildThreadListV2ListItems, getThreadListV2OrderedSection, resolveThreadListV2SnoozeMenuSelection, + resolveThreadListV2SwipeActions, resolveThreadListV2Status, resolveThreadListV2WokeAt, sortThreadsForListV2, @@ -122,32 +124,35 @@ describe("resolveThreadListV2Status", () => { ); }); -describe("queued messages keep a settled thread active", () => { - const threads = [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), - makeThread({ - id: ThreadId.make("settled-queued"), - title: "Settled with outbox", - settledOverride: "settled", - }), - ]; - const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); +describe("resolveThreadListV2SwipeActions", () => { + it("offers snooze for a presented input request and moves it onto the snoozed shelf", () => { + const raw = makeRawThreadShell({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input", + createdAt: DateTime.makeUnsafe(NOW), + }, + }); + const thread = presentThreadShell(environmentId, raw); + expect(thread.pendingUserInputAt).toBe(NOW); + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: true, + snoozable: canSnooze(thread, { now: NOW }), + }).secondary, + ).toBe("snooze"); - it("lists the thread in the active block instead of the settled shelf", () => { const layout = buildThreadListV2Items({ - threads, + threads: [{ ...thread, snoozedAt: NOW, snoozedUntil: "2026-06-03T00:00:00.000Z" }], environmentId: null, searchQuery: "", now: NOW, - queuedThreadKeys, + snoozedShelfExpanded: true, + snoozeEnvironmentIds: new Set([environmentId]), }); - expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ - ["active", "card"], - ["settled-queued", "card"], - ["settled", "slim"], - ]); - expect(layout.settledCount).toBe(1); + expect(layout.snoozedCount).toBe(1); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 78a65df9062e..a670fd287ed3 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -128,7 +128,7 @@ export function resolveThreadListV2SnoozeGateExpiryMs( >, options: { readonly now: string }, ): number | null { - if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; + if (thread.hasPendingApprovals) return null; if (thread.pendingSubagentRequest != null) return null; if (!hasQueuedTurnStart(thread, options)) return null; const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 6259fd78db62..3e9808b5d56d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -440,6 +440,14 @@ it.effect( assert.deepEqual(yield* outbox.listByCommandId(commandId), []); } } + if (mode === "live") { + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("snooze-live-control"), + threadId, + snoozedUntil: "2099-07-25T09:00:00.000Z", + }); + } yield* orchestrator.dispatch( mode === "live" ? { @@ -464,6 +472,8 @@ it.effect( assert.equal(response.node?.status, mode === "live" ? "completed" : "cancelled"); assert.equal(response.item?.status, mode === "live" ? "completed" : "cancelled"); if (mode === "live") { + assert.isNull((yield* projections.getThread(threadId)).snoozedUntil); + assert.isNull((yield* projections.getThread(threadId)).snoozedAt); assert.deepEqual(response.request?.answers, normalizedAnswers); assert.equal(response.item?.type, "user_input_request"); if (response.item?.type === "user_input_request") { @@ -1367,3 +1377,89 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r assert.deepInclude((yield* projections.getThreadProjection(threadId)).thread, active); }).pipe(Effect.provide(layerTest)), ); + +it.effect("keeps a snooze when a live question is cancelled or an approval is answered", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const now = yield* DateTime.now; + for (const kind of ["user_input", "command"] as const) { + const threadId = ThreadId.make(`thread:snoozed-live-${kind}`); + const requestId = RuntimeRequestId.make(`request:snoozed-live-${kind}`); + const sessionId = ProviderSessionId.make(`session:snoozed-live-${kind}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-snoozed-live-${kind}`), + threadId, + projectId: ProjectId.make("project:snoozed-live"), + title: "Deferred question", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`snooze-live-${kind}`), + threadId, + snoozedUntil: "2099-07-25T09:00:00.000Z", + }); + const before = yield* projections.getThread(threadId); + yield* projections.apply({ + id: EventId.make(`attach-snoozed-live-${kind}`), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: sessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* projections.apply({ + id: EventId.make(`request-snoozed-live-${kind}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: requestId, + nodeId: NodeId.make(`node:snoozed-live-${kind}`), + providerTurnId: null, + nativeRequestRef: null, + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId: sessionId }, + createdAt: now, + resolvedAt: null, + }, + }); + const commandId = CommandId.make(`respond-snoozed-live-${kind}`); + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId, + threadId, + requestId, + decision: kind === "user_input" ? "cancel" : "accept", + }); + const after = yield* projections.getThread(threadId); + assert.deepEqual(after.snoozedUntil, before.snoozedUntil); + assert.deepEqual(after.snoozedAt, before.snoozedAt); + assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "resolved"); + assert.equal( + (yield* outbox.listByCommandId(commandId))[0]?.request.type, + "runtime-request.respond", + ); + } + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5dd48ba548d2..3e8001a926fd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3006,11 +3006,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future.`, }); } - if (projection.runtimeRequests.some((request) => request.status === "pending")) { + if ( + projection.runtimeRequests.some( + (request) => request.status === "pending" && request.kind !== "user_input", + ) + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed.`, + cause: `Thread ${command.threadId} has a pending approval and cannot be snoozed.`, }); } const queuedRuns = projection.runs.filter((run) => run.status === "queued"); @@ -7938,6 +7942,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); } if (providerSessionId === null) return; + if (runtimeRequest.kind === "user_input" && resolvedNodeStatus === "completed") { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe(mapDispatchError(command)); + if (thread.snoozedUntil != null) { + yield* emitEvent({ + type: "thread.unsnoozed", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, snoozedUntil: null, snoozedAt: null, updatedAt: now }, + }); + } + } yield* Ref.update(effects, (existing) => [ ...existing, { diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index d80bc8dd73c1..6c39e9c46dd4 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -5,6 +5,7 @@ import { NodeId, type OrchestrationV2AppThread, type OrchestrationV2Run, + type OrchestrationV2RuntimeRequest, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -20,7 +21,11 @@ import * as Layer from "effect/Layer"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; +import { + isAutoSettlementCandidate, + isSnoozed, + resolveAutoSettlementAt, +} from "./ThreadSettlementService.ts"; const layerSql = ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)); const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); @@ -136,6 +141,78 @@ const createItem = Effect.fn(function* ( }); }); +it.effect.each([ + ["sql", layerSql], + ["memory", ProjectionStore.layerMemory], +] as const)( + "%s: pending approvals wake a snoozed question even with older creation times", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("snoozed-question-approval", { + snoozedAt: now, + snoozedUntil: DateTime.add(now, { days: 1 }), + }); + const question: OrchestrationV2RuntimeRequest = { + id: RuntimeRequestId.make("question"), + nodeId: NodeId.make("node:question"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: DateTime.subtract(now, { hours: 1 }), + resolvedAt: null, + }; + const approval: OrchestrationV2RuntimeRequest = { + ...question, + id: RuntimeRequestId.make("approval"), + kind: "command", + createdAt: old, + }; + const newerApproval = { + ...approval, + id: RuntimeRequestId.make("newer-approval"), + createdAt: DateTime.add(old, { hours: 1 }), + }; + const setRequest = (request: OrchestrationV2RuntimeRequest) => + store.apply({ + id: EventId.make(`event:${request.id}:${request.status}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: request, + }); + const assertPending = (expected: RuntimeRequestId, snoozed: boolean) => + Effect.gen(function* () { + const snapshot = yield* store.getShellSnapshot(); + const shell = snapshot.threads.find((thread) => thread.id === threadId)!; + const projection = yield* store.getThreadProjection(threadId); + for (const candidate of [shell, ProjectionStore.threadShellFromProjection(projection)]) { + assert.equal(candidate.pendingRuntimeRequest?.id, expected); + assert.equal(isSnoozed(candidate, DateTime.toEpochMillis(now)), snoozed); + } + }); + yield* setRequest({ + ...question, + id: RuntimeRequestId.make("older-question"), + createdAt: old, + }); + yield* setRequest(question); + yield* assertPending(question.id, true); + + // A request becoming pending can retain a creation time older than the snooze. + yield* setRequest(approval); + yield* assertPending(approval.id, false); + yield* setRequest(newerApproval); + yield* assertPending(newerApproval.id, false); + yield* setRequest({ ...newerApproval, status: "resolved", resolvedAt: now }); + yield* assertPending(approval.id, false); + yield* setRequest({ ...approval, status: "resolved", resolvedAt: now }); + yield* assertPending(question.id, true); + }).pipe(Effect.provide(testLayer)), +); + it.effect.each([ ["sql", layerSql], ["memory", ProjectionStore.layerMemory], diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4f08378db528..e7932964a39f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1538,6 +1538,7 @@ export function threadShellFromProjection( .filter((request) => request.status === "pending") .toSorted( (left, right) => + Number(left.kind === "user_input") - Number(right.kind === "user_input") || DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt), )[0] ?? null, ); @@ -5729,7 +5730,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' - ORDER BY request.created_at DESC, request.runtime_request_id DESC + ORDER BY (request.kind = 'user_input') ASC, + request.created_at DESC, request.runtime_request_id DESC LIMIT 1 ) AS pending_request_payload_json, ( diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 10caf988e475..9e897152e0bd 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -6,6 +6,7 @@ import { ProjectId, EventId, ProviderInstanceId, + RuntimeRequestId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2AppThread, @@ -1062,6 +1063,37 @@ describe("ThreadSettlementServiceV2 terminals", () => { describe("isSnoozed", () => { const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) }; + it("defers existing input while new questions and approvals wake the thread", () => { + const request = { + id: RuntimeRequestId.make("question"), + kind: "user_input" as const, + createdAt: snoozed.snoozedAt, + }; + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, pendingRuntimeRequest: request }), + NOW_MS, + ), + ).toBe(true); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, pendingRuntimeRequest: { ...request, createdAt: at(-DAY_MS) } }), + NOW_MS, + ), + ).toBe(false); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, pendingRuntimeRequest: { ...request, kind: "command" } }), + NOW_MS, + ), + ).toBe(false); + expect( + ThreadSettlementService.isSnoozed( + shell({ ...snoozed, snoozedAt: null, pendingRuntimeRequest: request }), + NOW_MS, + ), + ).toBe(false); + }); it("keeps a run requested and completed after snoozing parked until the timer", () => { const thread = shell({ ...snoozed, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 7d95dfd035a3..751f1543e02f 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -183,8 +183,15 @@ export function isSnoozed( ): boolean { const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false; - if (thread.pendingRuntimeRequest !== null) return false; const snoozedAtMs = toMillis(thread.snoozedAt); + if ( + thread.pendingRuntimeRequest !== null && + (thread.pendingRuntimeRequest.kind !== "user_input" || + snoozedAtMs === null || + DateTime.toEpochMillis(thread.pendingRuntimeRequest.createdAt) > snoozedAtMs) + ) { + return false; + } const completedAtMs = toMillis(thread.latestRunCompletedAt); const wokeOnError = thread.status === "failed" && diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1c9404da6b92..dda9f50d769e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1078,7 +1078,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); - it.effect("answers an async question after its provider exits and commits the answer once", () => + it.effect("snoozes an async question without losing it and commits its answer once", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; @@ -1175,6 +1175,51 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }, ], }); + const beforeSnooze = yield* orchestrator.getThreadProjection(threadId); + const snooze = { + type: "thread.snooze" as const, + commandId: CommandId.make("runtime-async-question-snooze"), + threadId, + snoozedUntil: "2099-07-25T09:00:00.000Z", + }; + yield* orchestrator.dispatch(snooze); + const snoozed = yield* orchestrator.getThreadProjection(threadId); + assert.isNotNull(snoozed.thread.snoozedUntil); + assert.deepEqual(snoozed.runtimeRequests, beforeSnooze.runtimeRequests); + assert.deepEqual(snoozed.turnItems, beforeSnooze.turnItems); + assert.deepEqual(snoozed.nodes, beforeSnooze.nodes); + assert.deepEqual(snoozed.runs, beforeSnooze.runs); + + // A question arriving after an earlier snooze must be acknowledged even + // when the user picks the same wake time again. + const priorSnooze = DateTime.makeUnsafe(DateTime.toEpochMillis(now) - 1_000); + yield* eventSink.write({ + commandId: CommandId.make("runtime-async-question-earlier-snooze"), + events: [ + { + id: EventId.make("runtime-async-question-earlier-snooze-event"), + type: "thread.snoozed", + threadId, + occurredAt: priorSnooze, + payload: { ...snoozed.thread, snoozedAt: priorSnooze }, + }, + ], + }); + yield* orchestrator.dispatch({ + ...snooze, + commandId: CommandId.make("runtime-async-question-resnooze"), + }); + assert.deepEqual((yield* orchestrator.getThreadProjection(threadId)).thread.snoozedAt, now); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-async-question-wake"), + threadId, + reason: "user", + }); + const awake = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(awake.thread.snoozedUntil); + assert.deepEqual(awake.runtimeRequests, beforeSnooze.runtimeRequests); + const invalid = yield* orchestrator .dispatch({ type: "runtime-request.respond", diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c2c02ca5be61..8afca73c488d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -237,6 +237,8 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in local time zone, or a duration in minutes, hours, or days. Durations start when you confirm; one day means 24 hours. On web and desktop, you can also snooze several selected threads together. Choose **Wake thread** to bring a thread back early. + A snoozed thread keeps working and stays hidden until its wake time, even while -its agent wakes itself for background work. It comes back early only when it needs -an approval or answer from you, when a run fails, or when you send it a message. +its agent wakes itself for background work. It comes back early for an approval, +a new question, a failed run, or a message or answer you send. You can snooze an existing +question and answer when the thread wakes; the question stays unanswered. diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 0b89a79b2adb..5868b32e1197 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -41,7 +41,7 @@ describe("V2 client presentation", () => { const active = presentThreadShell(environmentId, running); expect(active.hasPendingUserInput).toBe(false); expect(isThreadWorking(active)).toBe(true); - expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); + expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(true); const completed = presentThreadShell(environmentId, { ...running, diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 537f1455a412..1401a5f96764 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -155,6 +155,7 @@ export interface EnvironmentThreadShell { readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + readonly pendingUserInputAt?: string | null; readonly pendingSubagentRequest?: PendingSubagentRequest | null; readonly hasActionableProposedPlan: boolean; readonly pendingBackgroundTasks: ReadonlyArray< @@ -309,6 +310,10 @@ export function presentThreadShell( hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input" && (thread.pendingRuntimeRequest.blocking !== false || !runtimeIsWorking), + pendingUserInputAt: + thread.pendingRuntimeRequest?.kind === "user_input" + ? iso(thread.pendingRuntimeRequest.createdAt) + : null, pendingSubagentRequest, hasActionableProposedPlan: thread.hasActionableProposedPlan, pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index cdc7bcb57a94..ba66adedd634 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -367,14 +367,52 @@ describe("remote thread lifecycle commands", () => { input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, }); const request = yield* Queue.take(h.requests); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(stale.threads[0]); + if (action === "settle") { + expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(stale.threads[0]); + } else { + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject({ + snoozedUntil: FUTURE, + pendingRuntimeRequest: USER_INPUT, + }); + } yield* Deferred.succeed(request.reply, { sequence: 2 }); expect((yield* Effect.promise(() => result))._tag).toBe("Success"); expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject( action === "settle" ? { settledOverride: "settled" } : { snoozedUntil: FUTURE }, ); - expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull(); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toEqual( + action === "settle" ? null : USER_INPUT, + ); expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale); }), ); + it.effect("acknowledges a new question when snoozing again to the same wake time", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const initial = { + ...SNAPSHOT, + threads: [ + { + ...SNAPSHOT.threads[0]!, + pendingRuntimeRequest: USER_INPUT, + snoozedAt: DateTime.makeUnsafe("2026-09-12T09:00:00.000Z"), + snoozedUntil: FUTURE, + }, + ], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), initial); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, + }); + const request = yield* Queue.take(h.requests); + const snoozed = h.registry.get(h.visibleAtom)?.threads[0]; + expect(DateTime.toEpochMillis(snoozed!.snoozedAt!)).toBeGreaterThanOrEqual( + DateTime.toEpochMillis(USER_INPUT.createdAt), + ); + expect(snoozed?.pendingRuntimeRequest).toEqual(USER_INPUT); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 0ef8d2d6e179..a365a039b13a 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -436,13 +436,13 @@ export function createThreadEnvironmentAtoms( })), snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => (!accepted && - (thread.pendingRuntimeRequest !== null || + ((thread.pendingRuntimeRequest !== null && + thread.pendingRuntimeRequest.kind !== "user_input") || ["preparing", "queued", "starting"].includes(thread.status))) || !(Date.parse(input.snoozedUntil) > DateTime.toEpochMillis(now)) ? thread : { ...thread, - pendingRuntimeRequest: null, snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), snoozedAt: now, }, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index cd156ccdc2e0..30a491bb376a 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -76,17 +76,37 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + readonly pendingUserInputAt?: string | null; readonly pendingSubagentRequest?: PendingSubagentRequest | null; } +function pendingQuestionAt(shell: ThreadSnoozeShell): string | null { + const request = shell.source?.pendingRuntimeRequest; + return ( + shell.pendingUserInputAt ?? + (request?.kind === "user_input" ? DateTime.formatIso(request.createdAt) : null) + ); +} + /** * Only a request for the user or a fresh failure ends a snooze early. Completed runs never do: * a snoozed long-running thread keeps waking itself for background work and stays hidden * until its timer. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; - if (shell.source?.pendingRuntimeRequest != null) return true; + const request = shell.source?.pendingRuntimeRequest; + if (shell.hasPendingApprovals || (request != null && request.kind !== "user_input")) return true; + const questionAt = pendingQuestionAt(shell); + // Snoozing an existing question means "ask me later". Only a new question + // wakes it early; missing or invalid timestamps keep the request visible. + if ( + (shell.hasPendingUserInput || request?.kind === "user_input") && + (shell.snoozedAt == null || + questionAt == null || + !(Date.parse(questionAt) <= Date.parse(shell.snoozedAt))) + ) { + return true; + } if (shell.pendingSubagentRequest != null) return true; const failedAt = failureEdgeAt(shell); return ( @@ -123,9 +143,9 @@ export function canSnooze( >, options: { readonly now: string }, ): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - // The server rejects snoozing even for nonblocking questions. - if (shell.source?.pendingRuntimeRequest != null) return false; + if (shell.hasPendingApprovals) return false; + const request = shell.source?.pendingRuntimeRequest; + if (request != null && request.kind !== "user_input") return false; if (shell.pendingSubagentRequest != null) return false; if (hasQueuedTurnStart(shell, options)) return false; return true; @@ -154,14 +174,19 @@ export function threadWokeAt( // indicator the user already cleared by visiting (snoozedUntil is newer // than that visit's lastVisitedAt). if (threadRaisedHandWhileSnoozed(shell)) { - // An old failure must not replace the activity time of an approval or input wake. const failedAt = failureEdgeAt(shell); - if ( + const questionAt = pendingQuestionAt(shell); + const freshFailure = failedAt != null && - (shell.snoozedAt == null || Date.parse(failedAt) > Date.parse(shell.snoozedAt)) - ) { + (shell.snoozedAt == null || Date.parse(failedAt) > Date.parse(shell.snoozedAt)); + const freshQuestion = + questionAt != null && + shell.snoozedAt != null && + Date.parse(questionAt) > Date.parse(shell.snoozedAt); + if (freshFailure && (!freshQuestion || Date.parse(failedAt) >= Date.parse(questionAt))) { return failedAt; } + if (freshQuestion) return questionAt; const runtime = shell.runtime ?? shell.session ?? null; return runtime?.updatedAt ?? shell.snoozedAt ?? null; } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 3c9d6fded5dc..0f108bfd0a5e 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,4 +1,6 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. +import * as DateTime from "effect/DateTime"; +import { RuntimeRequestId } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -28,6 +30,7 @@ function makeShell(input: { readonly snoozedAt?: string | null; readonly sessionStatus?: "starting" | "running" | "ready" | "error"; readonly pending?: "approval" | "user-input"; + readonly pendingUserInputAt?: string | null; readonly turnRequestedAt?: string | null; readonly turnCompletedAt?: string | null; }): ThreadSnoozeShell { @@ -37,6 +40,7 @@ function makeShell(input: { snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), hasPendingApprovals: input.pending === "approval", hasPendingUserInput: input.pending === "user-input", + pendingUserInputAt: input.pendingUserInputAt ?? null, session: input.sessionStatus === undefined ? null @@ -87,6 +91,64 @@ describe("effectiveSnoozed", () => { ).toBe(false); }); + it("defers an existing question until the timer, but wakes for a new question", () => { + const shell = makeShell({ + snoozedUntil: FUTURE_WAKE, + pending: "user-input", + pendingUserInputAt: SNOOZED_AT, + }); + expect(effectiveSnoozed(shell, { now: NOW })).toBe(true); + expect(threadWokeAt(shell, { now: NOW })).toBeNull(); + expect(effectiveSnoozed(shell, { now: FUTURE_WAKE })).toBe(false); + expect(threadWokeAt(shell, { now: FUTURE_WAKE })).toBe(FUTURE_WAKE); + + const newQuestion = { ...shell, pendingUserInputAt: "2026-04-10T10:00:00.000Z" }; + expect(effectiveSnoozed(newQuestion, { now: NOW })).toBe(false); + expect(threadWokeAt(newQuestion, { now: NOW })).toBe(newQuestion.pendingUserInputAt); + expect(effectiveSnoozed({ ...shell, pendingUserInputAt: "invalid" }, { now: NOW })).toBe(false); + }); + + it.each([false, true])( + "snoozes existing source questions with blocking=%s and wakes for newer ones", + (blocking) => { + const shell: ThreadSnoozeShell = { + ...makeShell({ snoozedUntil: FUTURE_WAKE }), + source: { + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input", + blocking, + createdAt: DateTime.makeUnsafe(SNOOZED_AT), + }, + }, + }; + expect(canSnooze(shell, { now: NOW })).toBe(true); + expect(effectiveSnoozed(shell, { now: NOW })).toBe(true); + const newQuestion = { + ...shell, + source: { + pendingRuntimeRequest: { + ...shell.source!.pendingRuntimeRequest!, + createdAt: DateTime.makeUnsafe("2026-04-10T10:00:00.000Z"), + }, + }, + }; + expect(effectiveSnoozed(newQuestion, { now: NOW })).toBe(false); + expect(threadWokeAt(newQuestion, { now: NOW })).toBe("2026-04-10T10:00:00.000Z"); + const approval = { + ...shell, + source: { + pendingRuntimeRequest: { + ...shell.source!.pendingRuntimeRequest!, + kind: "command" as const, + }, + }, + }; + expect(canSnooze(approval, { now: NOW })).toBe(false); + expect(effectiveSnoozed(approval, { now: NOW })).toBe(false); + }, + ); + it.each([ { reason: "stale failure", @@ -168,7 +230,7 @@ describe("threadRaisedHandWhileSnoozed", () => { }); describe("canSnooze", () => { - it("refuses blocked-on-you work", () => { + it("allows deferring user input while refusing approvals and subagent requests", () => { expect( canSnooze({ ...makeShell({ pending: "approval" }), latestUserMessageAt: null }, { now: NOW }), ).toBe(false); @@ -177,7 +239,7 @@ describe("canSnooze", () => { { ...makeShell({ pending: "user-input" }), latestUserMessageAt: null }, { now: NOW }, ), - ).toBe(false); + ).toBe(true); expect( canSnooze( { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" }, @@ -258,6 +320,45 @@ describe("threadWokeAt", () => { expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T11:00:00.000Z"); }); + it.each(["failure", "subagent request"] as const)( + "keeps an old question from masking a newer %s wake", + (wake) => { + const shell = { + ...makeShell({ + snoozedUntil: FUTURE_WAKE, + pending: "user-input", + pendingUserInputAt: SNOOZED_AT, + sessionStatus: wake === "failure" ? "error" : "running", + }), + pendingSubagentRequest: wake === "subagent request" ? ("user_input" as const) : null, + }; + const lastVisitedAt = "2026-04-10T10:00:00.000Z"; + expect(effectiveSnoozed(shell, { now: NOW })).toBe(false); + const wokeAt = threadWokeAt(shell, { now: NOW }); + expect(wokeAt).toBe("2026-04-10T11:00:00.000Z"); + expect(Date.parse(wokeAt!)).toBeGreaterThan(Date.parse(lastVisitedAt)); + }, + ); + + it.each([ + ["error", "2026-04-10T10:00:00.000Z", "2026-04-10T11:00:00.000Z"], + ["failed", "2026-04-10T10:00:00.000Z", "2026-04-10T11:00:00.000Z"], + ["failed", "2026-04-10T11:30:00.000Z", "2026-04-10T11:30:00.000Z"], + ["running", "2026-04-10T10:00:00.000Z", "2026-04-10T10:00:00.000Z"], + ] as const)( + "uses the latest wake event for a %s runtime and a question at %s", + (status, pendingUserInputAt, expectedWakeAt) => { + const shell = { + ...makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input", pendingUserInputAt }), + runtime: { status, updatedAt: "2026-04-10T11:00:00.000Z" }, + }; + const lastVisitedAt = "2026-04-10T10:30:00.000Z"; + const wokeAt = threadWokeAt(shell, { now: NOW }); + expect(wokeAt).toBe(expectedWakeAt); + expect(Date.parse(wokeAt!) > Date.parse(lastVisitedAt)).toBe(status !== "running"); + }, + ); + it("keeps the early wake authoritative after the scheduled time passes", () => { const shell: ThreadSnoozeShell = { ...makeShell({ snoozedUntil: PAST_WAKE }),