diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts index 799c1fdc2662..c1fcc4409dc4 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.test.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -24,7 +24,11 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ServerConfig } from "../../config.ts"; -import { hermesPromptSettlementBelongsToContext, makeHermesAdapter } from "./HermesAdapter.ts"; +import { + hermesPromptSettlementBelongsToContext, + makeHermesAdapter, + selectPermissionOptionId, +} from "./HermesAdapter.ts"; const decodeHermesSettings = Schema.decodeSync(HermesSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -110,6 +114,24 @@ it("requires a settlement to match the originating Hermes generation", () => { ); }); +it("maps every accept-shaped decision to an accept-shaped permission option", () => { + const request = { + sessionId: "session-1", + options: [ + { optionId: "opt-always", kind: "allow_always", name: "Always allow" }, + { optionId: "opt-once", kind: "allow_once", name: "Allow once" }, + { optionId: "opt-reject", kind: "reject_once", name: "Reject" }, + ], + toolCall: { toolCallId: "tool-1" }, + } as const; + assert.equal(selectPermissionOptionId(request, "accept"), "opt-once"); + assert.equal(selectPermissionOptionId(request, "acceptForSession"), "opt-always"); + // acceptAlways is not offered by Hermes today, but a client sending it must + // never fall through to a rejection. + assert.equal(selectPermissionOptionId(request, "acceptAlways"), "opt-always"); + assert.equal(selectPermissionOptionId(request, "decline"), "opt-reject"); +}); + it.layer(hermesAdapterTestLayer)("HermesAdapter", (it) => { it.effect("surfaces terminal-only Hermes authentication during session startup", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts index ac9956c86145..b8eea140c8f2 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -274,12 +274,15 @@ function parseHermesResume(raw: unknown): }; } -function selectPermissionOptionId( +export function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { + // Hermes never offers an acceptAlways option today, but the decision union + // is provider-agnostic: a client sending it must land on the closest + // accept-shaped option, never fall through to a silent rejection. const kind = - decision === "acceptForSession" + decision === "acceptForSession" || decision === "acceptAlways" ? "allow_always" : decision === "accept" ? "allow_once" diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b06a51bdf01f..a4c933890f36 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -29,7 +29,7 @@ import { } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { - changeRequestAutoSettles, + changeRequestMutesWakeSignal, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -4591,8 +4591,11 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; if ( - changeRequestAutoSettles(activeThreadChangeRequest, { + changeRequestMutesWakeSignal({ + settlementSupported: supportsSettlement, + autoSettleEnabled, autoSettleOnMerge, + changeRequest: activeThreadChangeRequest, thread: activeThreadShell, }) ) { @@ -4620,7 +4623,9 @@ function ChatViewContent(props: ChatViewProps) { activeThreadChangeRequest, activeThreadShell, activeThreadWokeAt, + autoSettleEnabled, autoSettleOnMerge, + supportsSettlement, ]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a13bcc3ebc41..75a7becb3fa7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -20,7 +20,7 @@ import { CSS } from "@dnd-kit/utilities"; import { passesAttentionFilter } from "@t3tools/client-runtime/state/thread-attention"; import { canSnooze, - changeRequestAutoSettles, + changeRequestMutesWakeSignal, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -814,6 +814,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; + // The fork's auto-settle master gate. The Woke pill's change-request + // suppression only applies while the settle would actually happen. + autoSettleEnabled: boolean; autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; @@ -947,8 +950,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // rather than upstream's sticky-woke semantics, so mobile stays in step and // the comparison lives in the shared helper. Work that finished outright // also clears it: no wake-up call is needed for a thread the change-request - // state already settles, which is now the same parameterized rule the - // settle path uses (a merged PR only counts when merge auto-settling is on). + // state is about to settle — but only when that settle would actually + // happen (settlement supported, master gate on, thread not pinned active); + // the shared mute rule owns that judgment. // An unparseable visit timestamp counts as never-visited — corrupt local // data must not eat the wake signal. const isWoke = @@ -956,8 +960,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { wokeAt: props.wokeAt, ...(lastVisitedAt === undefined ? {} : { lastVisitedAt }), }) && - !changeRequestAutoSettles(pr, { + !changeRequestMutesWakeSignal({ + settlementSupported: props.settlementSupported, + autoSettleEnabled: props.autoSettleEnabled, autoSettleOnMerge: props.autoSettleOnMerge, + changeRequest: pr, thread, }); // In-flight rows (working, or waiting on approval/input) fade as a whole: @@ -1511,8 +1518,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.snoozeWakeLabelText} ) : isWoke ? ( - // A wake can land straight in the settled tail (e.g. PR - // merged while snoozed); the signal must survive the trip. + // A wake can land straight in the settled tail (an explicit + // settle, or a merge the wake-mute rule lets through, e.g. + // with auto-settling off); the signal must survive the trip. { }); }); +describe("changeRequestMutesWakeSignal", () => { + const wokeThread = { + createdAt: "2026-04-01T00:00:00.000Z", + latestUserMessageAt: null, + latestTurn: null, + settledOverride: null, + }; + const base = { + settlementSupported: true, + autoSettleEnabled: true, + changeRequest: { state: "merged" as const }, + thread: wokeThread, + }; + + it("mutes only while the settle would actually happen", () => { + expect(changeRequestMutesWakeSignal(base)).toBe(true); + // No settlement capability, master gate off, or an explicit keep-active + // pin: the thread never settles, so the wake signal must carry through. + expect(changeRequestMutesWakeSignal({ ...base, settlementSupported: false })).toBe(false); + expect(changeRequestMutesWakeSignal({ ...base, autoSettleEnabled: false })).toBe(false); + expect( + changeRequestMutesWakeSignal({ + ...base, + thread: { ...wokeThread, settledOverride: "active" }, + }), + ).toBe(false); + }); + + it("defers to the change-request settle rule past the gates", () => { + expect(changeRequestMutesWakeSignal({ ...base, changeRequest: { state: "open" } })).toBe(false); + expect(changeRequestMutesWakeSignal({ ...base, changeRequest: null })).toBe(false); + expect(changeRequestMutesWakeSignal({ ...base, autoSettleOnMerge: false })).toBe(false); + expect(changeRequestMutesWakeSignal({ ...base, changeRequest: { state: "closed" } })).toBe( + true, + ); + }); +}); + function makeShell(input: { readonly settledOverride?: "settled" | "active" | null; readonly activityAt: string | null; diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 8b9b68eb898a..853ad1f59e06 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -69,6 +69,33 @@ export function changeRequestAutoSettles( return updatedAtMs >= anchorAtMs; } +/** + * Whether a terminal change request should mute the thread's wake signal + * (the Woke pill / banner): finished work needs no wake-up call, but only + * while the settle would actually happen. A thread on a server without + * settlement, behind the fork's auto-settle master gate, or explicitly + * pinned active never settles on its change request — it stays in the + * active list, so the wake signal has to carry through. Shared by both web + * surfaces so the pill and the banner can never disagree. + */ +export function changeRequestMutesWakeSignal(options: { + readonly settlementSupported: boolean; + readonly autoSettleEnabled: boolean; + readonly autoSettleOnMerge?: boolean | undefined; + readonly changeRequest: ChangeRequestSettleSource | null | undefined; + readonly thread: + | (ThreadActivitySource & Pick) + | null + | undefined; +}): boolean { + if (!options.settlementSupported || !options.autoSettleEnabled) return false; + if (options.thread?.settledOverride === "active") return false; + return changeRequestAutoSettles(options.changeRequest, { + autoSettleOnMerge: options.autoSettleOnMerge, + thread: options.thread, + }); +} + const DAY_MS = 24 * 60 * 60 * 1_000; export function threadLastActivityAt(