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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: approval and Woke follow-ups from the upstream sync review by pandec · Pull Request #171 · pandec/t3code · GitHub
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
changeRequestAutoSettles,
changeRequestMutesWakeSignal,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
Expand DownExpand 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 numberDiff line numberDiff 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