diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 73f1cbf9127..7e17ae680fa 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -24,6 +24,11 @@ function makeReadModel( session: OrchestrationSession | null = null, activities: OrchestrationThread["activities"] = [], messages: OrchestrationThread["messages"] = [], + lifecycle: { + readonly pinnedAt?: string | null; + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; + } = {}, ): OrchestrationReadModel { return { snapshotSequence: 0, @@ -44,6 +49,9 @@ function makeReadModel( archivedAt, settledOverride, settledAt: settledOverride === "settled" ? SETTLED_AT : null, + snoozedUntil: lifecycle.snoozedUntil ?? null, + snoozedAt: lifecycle.snoozedAt ?? (lifecycle.snoozedUntil != null ? SETTLED_AT : null), + pinnedAt: lifecycle.pinnedAt ?? null, deletedAt: null, messages, proposedPlans: [], @@ -69,7 +77,7 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { - it.effect("settles active threads and re-emits idempotently for settled ones", () => + it.effect("settles awake threads without a redundant wake and re-emits idempotently", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ command: { @@ -108,6 +116,75 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + it.effect("settling a snoozed thread also wakes it", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-snoozed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [], { + snoozedUntil: "1970-01-02T09:00:00.000Z", + }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]); + const settled = events.find((entry) => entry.type === "thread.settled"); + const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); + if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") { + expect(unsnoozed.payload.reason).toBe("user"); + expect(unsnoozed.payload.updatedAt).toBe(settled.payload.updatedAt); + } + }), + ); + + it.effect("repeated settle repairs legacy settled and snoozed state", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-snoozed-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled", null, null, [], [], { + snoozedUntil: "1970-01-02T09:00:00.000Z", + }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]); + const settled = events.find((entry) => entry.type === "thread.settled"); + const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); + if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") { + expect(settled.payload.settledAt).toBe(SETTLED_AT); + expect(settled.payload.updatedAt).toBe(NOW); + expect(unsnoozed.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("settling a pinned and snoozed thread clears the pin and snooze", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pinned-snoozed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [], { + pinnedAt: SETTLED_AT, + snoozedUntil: "1970-01-02T09:00:00.000Z", + }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((entry) => entry.type)).toEqual([ + "thread.settled", + "thread.unpinned", + "thread.unsnoozed", + ]); + }), + ); + it.effect("rejects settling a thread with a live session", () => Effect.gen(function* () { for (const status of ["starting", "running"] as const) { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93d..ce1052a15ed 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -504,16 +504,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: alreadySettled ? thread.updatedAt : occurredAt, }, }; - // Settling is "I'm done with this": it clears a pin the same way it - // parks the thread. Without this, settling a pinned thread would only - // stamp invisible state — the pin would hold the card in place until - // a separate unpin. - if (thread.pinnedAt == null) { - return settledEvent; - } - return [ - settledEvent, - { + // Settling is "I'm done with this": clear states that would keep the + // row pinned or snoozed instead of showing the new settled state. + const companionEvents: Array> = []; + if (thread.pinnedAt != null) { + companionEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -525,8 +520,25 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, updatedAt: occurredAt, }, - }, - ]; + }); + } + if (thread.snoozedUntil != null) { + companionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return companionEvents.length > 0 ? [settledEvent, ...companionEvents] : settledEvent; } case "thread.unsettle": {