From 2b336c256f5ac8979c2896d608cebe535c2f6856 Mon Sep 17 00:00:00 2001 From: pandec Date: Mon, 24 Aug 2026 12:08:31 +0200 Subject: [PATCH 1/3] fix(server): map acceptAlways to an accept option in Hermes approvals A client sending the new acceptAlways decision to Hermes fell through the decision mapping's default and silently rejected the request. It now selects the allow_always option like acceptForSession does. Unreachable today (only Codex offers the option) but the decision union is provider-agnostic. Implemented by Claude Fable 5 via Claude Code. --- .../src/provider/Layers/HermesAdapter.test.ts | 24 ++++++++++++++++++- .../src/provider/Layers/HermesAdapter.ts | 7 ++++-- 2 files changed, 28 insertions(+), 3 deletions(-) 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" From 1bcd7ba281be03fe27ebf0b69e7940c1b108987a Mon Sep 17 00:00:00 2001 From: pandec Date: Mon, 24 Aug 2026 12:08:32 +0200 Subject: [PATCH 2/3] fix(web): show Woke indicators when auto-settle is off The sidebar Woke pill and the chat wake banner suppressed themselves whenever the thread's change request would auto-settle it, ignoring the fork's auto-settle master gate. With the gate off the thread never settles, so the wake signal must stay visible. Both sites now apply the suppression only while autoSettleEnabled is true. Implemented by Claude Fable 5 via Claude Code. --- apps/web/src/components/ChatView.tsx | 5 +++++ apps/web/src/components/Sidebar.tsx | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b06a51bdf01f..34889e4fcd56 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4590,7 +4590,11 @@ function ChatViewContent(props: ChatViewProps) { // upstream's second declaration here is deliberately not carried. const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; + // Suppression only applies while the settle would actually happen: with + // the fork's auto-settle master gate off, the thread stays in the active + // list and the wake signal has to carry through. if ( + autoSettleEnabled && changeRequestAutoSettles(activeThreadChangeRequest, { autoSettleOnMerge, thread: activeThreadShell, @@ -4620,6 +4624,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadChangeRequest, activeThreadShell, activeThreadWokeAt, + autoSettleEnabled, autoSettleOnMerge, ]); const activeThreadSettled = useMemo(() => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a13bcc3ebc41..5a62ef8b2b80 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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; @@ -948,7 +951,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // 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). + // settle path uses (a merged PR only counts when merge auto-settling is on, + // and nothing counts while the auto-settle master gate is off). // An unparseable visit timestamp counts as never-visited — corrupt local // data must not eat the wake signal. const isWoke = @@ -956,10 +960,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { wokeAt: props.wokeAt, ...(lastVisitedAt === undefined ? {} : { lastVisitedAt }), }) && - !changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }); + !( + props.autoSettleEnabled && + changeRequestAutoSettles(pr, { + autoSettleOnMerge: props.autoSettleOnMerge, + thread, + }) + ); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -4724,6 +4731,7 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } + autoSettleEnabled={autoSettleEnabled} autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities From da23a73b3347f3ed0451b2d3ffc2139ba89bcb20 Mon Sep 17 00:00:00 2001 From: pandec Date: Mon, 24 Aug 2026 12:15:38 +0200 Subject: [PATCH 3/3] fix(web): extract shared wake-mute rule covering all never-settles paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the Woke suppression also ignored the explicit keep-active pin and servers without the threadSettlement capability — both leave the thread active, so the wake signal must show. The predicate now lives in client-runtime as changeRequestMutesWakeSignal with tests, used by both the sidebar pill and the chat banner, and the two stale settled-tail comments were corrected. Implemented by Claude Fable 5 via Claude Code. --- apps/web/src/components/ChatView.tsx | 12 +++--- apps/web/src/components/Sidebar.tsx | 32 ++++++++------- .../src/state/threadSettled.test.ts | 39 +++++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 27 +++++++++++++ 4 files changed, 89 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 34889e4fcd56..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, @@ -4590,13 +4590,12 @@ function ChatViewContent(props: ChatViewProps) { // upstream's second declaration here is deliberately not carried. const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - // Suppression only applies while the settle would actually happen: with - // the fork's auto-settle master gate off, the thread stays in the active - // list and the wake signal has to carry through. if ( - autoSettleEnabled && - changeRequestAutoSettles(activeThreadChangeRequest, { + changeRequestMutesWakeSignal({ + settlementSupported: supportsSettlement, + autoSettleEnabled, autoSettleOnMerge, + changeRequest: activeThreadChangeRequest, thread: activeThreadShell, }) ) { @@ -4626,6 +4625,7 @@ function ChatViewContent(props: ChatViewProps) { 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 5a62ef8b2b80..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, @@ -950,9 +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, - // and nothing counts while the auto-settle master gate is off). + // 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 = @@ -960,13 +960,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { wokeAt: props.wokeAt, ...(lastVisitedAt === undefined ? {} : { lastVisitedAt }), }) && - !( - props.autoSettleEnabled && - changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }) - ); + !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: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -1518,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(