Skip to content
4 changes: 1 addition & 3 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
Expand Down
47 changes: 26 additions & 21 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +21,7 @@ import {
ProjectId,
ProviderInstanceId,
RunId,
RuntimeRequestId,
ThreadId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
Expand All @@ -33,6 +34,7 @@ import {
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SwipeActions,
resolveThreadListV2Status,
resolveThreadListV2WokeAt,
sortThreadsForListV2,
Expand Down Expand Up @@ -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);
});
});

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
? {
Expand All @@ -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") {
Expand Down Expand Up @@ -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)),
);
22 changes: 20 additions & 2 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}
const queuedRuns = projection.runs.filter((run) => run.status === "queued");
Expand Down Expand Up @@ -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,
{
Expand Down
79 changes: 78 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionSettlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
NodeId,
type OrchestrationV2AppThread,
type OrchestrationV2Run,
type OrchestrationV2RuntimeRequest,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
Expand All @@ -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");
Expand Down Expand Up @@ -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],
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -5729,7 +5730,8 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
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,
(
Expand Down
Loading
Loading