Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/server/src/provider/Layers/HermesAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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* () {
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/provider/Layers/HermesAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,15 @@ function parseHermesResume(raw: unknown):
};
}

function selectPermissionOptionId(
export function selectPermissionOptionId(
request: EffectAcpSchema.RequestPermissionRequest,
decision: Exclude<ProviderApprovalDecision, "cancel">,
): 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"
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
} from "@t3tools/client-runtime/connection";
import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
import {
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
Expand Down Expand Up @@ -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,
})
) {
Expand Down Expand Up @@ -4620,7 +4623,9 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadChangeRequest,
activeThreadShell,
activeThreadWokeAt,
autoSettleEnabled,
autoSettleOnMerge,
supportsSettlement,
]);
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
Expand Down
26 changes: 18 additions & 8 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -947,17 +950,21 @@ 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 =
hasUnseenWake({
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:
Expand Down Expand Up @@ -1511,8 +1518,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{props.snoozeWakeLabelText}
</span>
) : 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.
<Tooltip>
<TooltipTrigger
render={
Expand Down Expand Up @@ -4724,6 +4732,7 @@ export default function Sidebar() {
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadSettlement === true
}
autoSettleEnabled={autoSettleEnabled}
autoSettleOnMerge={autoSettleOnMerge}
snoozeSupported={
serverConfigs.get(thread.environmentId)?.environment.capabilities
Expand All @@ -4749,8 +4758,9 @@ export default function Sidebar() {
: null
}
// All sections: a woken thread can classify straight
// into the settled tail (PR merged while snoozed), and
// the wake signal must survive the trip. Still-snoozed
// into the settled tail (an explicit settle, or a
// merge the wake-mute rule lets through), and the
// wake signal must survive the trip. Still-snoozed
// rows resolve to null on their own.
wokeAt={threadWokeAt(thread, { now: snoozeNow })}
isActive={routeThreadKey === threadKey}
Expand Down
39 changes: 39 additions & 0 deletions packages/client-runtime/src/state/threadSettled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand Down Expand Up @@ -122,6 +123,44 @@ describe("changeRequestAutoSettles", () => {
});
});

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;
Expand Down
27 changes: 27 additions & 0 deletions packages/client-runtime/src/state/threadSettled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrchestrationThreadShell, "settledOverride">)
| 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(
Expand Down