Skip to content

Commit c0fd0e9

Browse files
committed
fix: thread toast duration through session hook config
- preserves toast_duration_ms when building the narrowed hook config - keeps dream notifications on the configured duration instead of falling back to 5s - includes regression coverage - keep restart/setup toasts on 10s fallback
1 parent 4e3635c commit c0fd0e9

15 files changed

Lines changed: 214 additions & 43 deletions

CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Higher-tier models with longer cache windows benefit from a longer TTL. Setting
113113
| `ctx_reduce_enabled` | `boolean` | `true` | When `false`, hides `ctx_reduce` tool, disables all nudges/reminders, and strips reduction guidance from prompts. Heuristic cleanup, compartments, memory, and search still work. Useful for testing whether automatic cleanup alone is sufficient. |
114114
| `cache_ttl` | `string` or `object` | `"5m"` | Time after a response before applying pending ops. String or per-model map. |
115115
| `protected_tags` | `number` (1–100) | `20` | Last N active tags immune from immediate dropping. |
116+
| `toast_duration_ms` | `number` (1000–60000) | `5000` | TUI toast lifetime for Magic Context notifications in milliseconds. Increase this if toasts disappear too quickly. |
116117
| `execute_threshold_percentage` | `number` (20–80) or `object` | `65` | Context usage that forces queued ops to execute. Capped at 80% max for cache safety. Supports per-model map. |
117118
| `execute_threshold_tokens` | `object` (per-model map) || **Optional absolute-tokens variant of `execute_threshold_percentage`.** Per-model map (e.g. `{ "default": 150000, "github-copilot/gpt-5.2-codex": 40000 }`). When set for a model, overrides the percentage-based threshold for that model. Clamped to `80% × context_limit` with a warn log. Requires a resolvable context limit — falls through to percentage if unavailable. See below. |
118119
| `clear_reasoning_age` | `number` | `50` | Clear thinking/reasoning blocks older than N tags. |
@@ -627,6 +628,7 @@ Tier boundaries are hardcoded to keep behavior predictable and prevent cache-bus
627628
"anthropic/claude-opus-4-6": 50
628629
},
629630
"protected_tags": 10,
631+
"toast_duration_ms": 12000,
630632
"history_budget_percentage": 0.15,
631633
"temporal_awareness": true,
632634

assets/magic-context.schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,13 @@
461461
}
462462
]
463463
},
464+
"toast_duration_ms": {
465+
"default": 5000,
466+
"description": "TUI toast lifetime in milliseconds for Magic Context notifications (min: 1000, max: 60000, default: 5000)",
467+
"type": "number",
468+
"minimum": 1000,
469+
"maximum": 60000
470+
},
464471
"execute_threshold_percentage": {
465472
"default": 65,
466473
"description": "Context percentage that forces queued operations to execute. Number or per-model object ({ default: 65, \"provider/model\": 45 }). Values above 80 are rejected because the runtime caps at 80% for cache safety (MAX_EXECUTE_THRESHOLD). Default: DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE",

packages/plugin/src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
33
import { join } from "node:path";
44

55
import { detectConfigFile, parseJsonc } from "../shared/jsonc-parser";
6+
import { log } from "../shared/logger";
67
import { migrateLegacyAgentEnabledInMemory } from "./agent-disable";
78
import { migrateLegacyExperimental } from "./migrate-experimental";
89
import {

packages/plugin/src/config/schema/magic-context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ export interface MagicContextConfig {
234234
historian?: HistorianConfig;
235235
dreamer?: DreamerConfig;
236236
cache_ttl: string | { default: string; [modelKey: string]: string };
237+
/** TUI toast lifetime in milliseconds for Magic Context notifications. Default: 5000. */
238+
toast_duration_ms?: number;
237239
execute_threshold_percentage: number | { default: number; [modelKey: string]: number };
238240
/** Absolute token thresholds per model. When set for a given model (or via `default`),
239241
* this overrides `execute_threshold_percentage` for that model. Useful for hard caps
@@ -364,6 +366,14 @@ export const MagicContextConfigSchema = z
364366
.describe(
365367
'Cache TTL: string (e.g. "5m") or per-model object ({ default: "5m", "model-id": "10m" })',
366368
),
369+
toast_duration_ms: z
370+
.number()
371+
.min(1_000)
372+
.max(60_000)
373+
.default(5_000)
374+
.describe(
375+
"TUI toast lifetime in milliseconds for Magic Context notifications (min: 1000, max: 60000, default: 5000)",
376+
),
367377
execute_threshold_percentage: z
368378
.union([
369379
z.number().min(20).max(80, EXECUTE_THRESHOLD_CAP_MESSAGE),

packages/plugin/src/hooks/magic-context/command-handler.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,13 +721,13 @@ describe("createMagicContextCommandHandler", () => {
721721
1,
722722
"ses-dream",
723723
"Starting dream run...",
724-
{},
724+
{ toastDurationMs: 5000 },
725725
);
726726
expect(sendNotification).toHaveBeenNthCalledWith(
727727
2,
728728
"ses-dream",
729729
expect.stringContaining("### Tasks"),
730-
{},
730+
{ toastDurationMs: 5000 },
731731
);
732732
});
733733

@@ -775,7 +775,7 @@ describe("createMagicContextCommandHandler", () => {
775775
3,
776776
"ses-dream",
777777
"Dream already queued for this project",
778-
{},
778+
{ toastDurationMs: 5000 },
779779
);
780780
expect(executeDream).toHaveBeenCalledTimes(1);
781781
});

packages/plugin/src/hooks/magic-context/command-handler.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ async function executeDreaming(
270270
text: string,
271271
params: NotificationParams,
272272
) => Promise<void>;
273+
toastDurationMs?: number;
273274
dreamer?: {
274275
config: DreamerConfig;
275276
projectPath: string;
@@ -288,11 +289,15 @@ async function executeDreaming(
288289
},
289290
sessionId: string,
290291
): Promise<never> {
292+
const dreamNotificationParams: NotificationParams = {
293+
toastDurationMs: deps.toastDurationMs ?? 5000,
294+
};
295+
291296
if (!deps.dreamer?.config?.tasks?.length) {
292297
await deps.sendNotification(
293298
sessionId,
294299
"## /ctx-dream\n\nDreaming is not configured for this project.",
295-
{},
300+
dreamNotificationParams,
296301
);
297302
throwSentinel("CTX-DREAM");
298303
}
@@ -303,11 +308,11 @@ async function executeDreaming(
303308
// runner with an unexpired lease is never deleted just because it is older than 2m.
304309
const entry = enqueueDream(deps.db, deps.dreamer.projectPath, "manual", true);
305310
if (!entry) {
306-
await deps.sendNotification(sessionId, "Dream already queued for this project", {});
311+
await deps.sendNotification(sessionId, "Dream already queued for this project", dreamNotificationParams);
307312
throwSentinel("CTX-DREAM");
308313
}
309314

310-
await deps.sendNotification(sessionId, "Starting dream run...", {});
315+
await deps.sendNotification(sessionId, "Starting dream run...", dreamNotificationParams);
311316

312317
const result = deps.dreamer.executeDream
313318
? await deps.dreamer.executeDream(sessionId)
@@ -334,7 +339,7 @@ async function executeDreaming(
334339
result
335340
? summarizeDreamResult(result)
336341
: "Dream queued, but another worker is already processing the queue.",
337-
{},
342+
dreamNotificationParams,
338343
);
339344
throwSentinel("CTX-DREAM");
340345
}
@@ -366,6 +371,8 @@ export function createMagicContextCommandHandler(deps: {
366371
text: string,
367372
params: NotificationParams,
368373
) => Promise<void>;
374+
/** Configured toast lifetime (ms) forwarded into diagnostics logs. */
375+
toastDurationMs?: number;
369376
sidekick?: {
370377
config: SidekickConfig;
371378
projectPath: string;
@@ -434,7 +441,11 @@ export function createMagicContextCommandHandler(deps: {
434441
if (isStatus) {
435442
if (isTuiConnected(sessionId)) {
436443
// In TUI, push an RPC action so the TUI poller shows a native dialog
437-
pushNotification("action", { action: "show-status-dialog" }, sessionId);
444+
pushNotification(
445+
"action",
446+
{ action: "show-status-dialog" },
447+
sessionId,
448+
);
438449
sessionLog(sessionId, "command ctx-status: pushed show-status-dialog to TUI");
439450
throwSentinel(input.command);
440451
}

packages/plugin/src/hooks/magic-context/hook-handlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,13 @@ export function getLiveNotificationParams(
130130
liveModelBySession: LiveModelBySession,
131131
variantBySession: VariantBySession,
132132
agentBySession?: AgentBySession,
133+
toastDurationMs?: number,
133134
): {
134135
agent?: string;
135136
variant?: string;
136137
providerId?: string;
137138
modelId?: string;
139+
toastDurationMs?: number;
138140
} {
139141
const model = liveModelBySession.get(sessionId);
140142
const variant = variantBySession.get(sessionId);
@@ -143,6 +145,7 @@ export function getLiveNotificationParams(
143145
...(agent ? { agent } : {}),
144146
...(variant ? { variant } : {}),
145147
...(model ? { providerId: model.providerID, modelId: model.modelID } : {}),
148+
...(typeof toastDurationMs === "number" ? { toastDurationMs } : {}),
146149
};
147150
}
148151

packages/plugin/src/hooks/magic-context/hook.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export interface MagicContextDeps {
7979
config: {
8080
protected_tags: number;
8181
ctx_reduce_enabled?: boolean;
82+
toast_duration_ms?: number;
8283
clear_reasoning_age?: number;
8384
execute_threshold_percentage?: number | { default: number; [modelKey: string]: number };
8485
execute_threshold_tokens?: { default?: number; [modelKey: string]: number | undefined };
@@ -333,7 +334,13 @@ export function createMagicContextHook(deps: MagicContextDeps) {
333334
userMemoriesEnabled: dreamerConfig?.user_memories?.enabled === true,
334335
ensureProjectRegistered: ensureProjectRegisteredFromOpenCodeDirectory,
335336
getNotificationParams: (sid) =>
336-
getLiveNotificationParams(sid, liveModelBySession, variantBySession, agentBySession),
337+
getLiveNotificationParams(
338+
sid,
339+
liveModelBySession,
340+
variantBySession,
341+
agentBySession,
342+
deps.config.toast_duration_ms,
343+
),
337344
});
338345
const sidekickRunnable = isSidekickRunnable(deps.config);
339346
const sidekickConfig = sidekickRunnable ? deps.config.sidekick : undefined;
@@ -379,6 +386,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
379386
liveModelBySession,
380387
variantBySession,
381388
agentBySession,
389+
deps.config.toast_duration_ms,
382390
),
383391
getModelKey: (sessionId) => {
384392
const model = liveModelBySession.get(sessionId);
@@ -444,6 +452,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
444452
liveModelBySession,
445453
variantBySession,
446454
agentBySession,
455+
deps.config.toast_duration_ms,
447456
),
448457
onSessionCacheInvalidated: (sessionId: string) => {
449458
clearInjectionCache(sessionId);
@@ -523,6 +532,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
523532
const commandHandler = createMagicContextCommandHandler({
524533
db,
525534
protectedTags: deps.config.protected_tags,
535+
toastDurationMs: deps.config.toast_duration_ms,
526536
executeThresholdPercentage: deps.config.execute_threshold_percentage ?? 65,
527537
executeThresholdTokens: deps.config.execute_threshold_tokens,
528538
historyBudgetPercentage: deps.config.history_budget_percentage,
@@ -573,6 +583,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
573583
liveModelBySession,
574584
variantBySession,
575585
agentBySession,
586+
deps.config.toast_duration_ms,
576587
),
577588
...params,
578589
});
@@ -697,6 +708,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
697708
liveModelBySession,
698709
variantBySession,
699710
agentBySession,
711+
deps.config.toast_duration_ms,
700712
),
701713
isTuiConnected,
702714
pushTuiDialogAction: (sid, resume) =>

packages/plugin/src/hooks/magic-context/send-session-notification.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface NotificationParams {
66
variant?: string;
77
providerId?: string;
88
modelId?: string;
9+
/** TUI toast lifetime in milliseconds (default: 5000). */
10+
toastDurationMs?: number;
911
}
1012

1113
export type NotificationDeliveryDisposition = "sent" | "skipped" | "failed";
@@ -71,31 +73,32 @@ export async function sendIgnoredMessage(
7173
// is too easy to miss — dogfood 2026-05-30.
7274
forcePersist = false,
7375
): Promise<NotificationDeliveryDisposition> {
76+
const title = extractToastTitle(text);
77+
const message = text.length > 200 ? `${text.slice(0, 200)}…` : text;
78+
const toastVariant = inferToastVariant(text);
79+
const duration = params.toastDurationMs ?? 5000;
80+
7481
// In TUI mode, show as toast via RPC instead of ignored message — UNLESS the
7582
// caller asked to force-persist (long-running outcome must stay in scrollback).
7683
// Cannot use process.env.OPENCODE_CLIENT — it's undefined in the server plugin process.
7784
const { isTuiConnected: checkTui } = await import("../../shared/rpc-notifications");
7885
if (!forcePersist && checkTui(sessionId)) {
7986
try {
80-
const c = client as Record<string, unknown>;
81-
const tui = c?.tui as Record<string, unknown> | undefined;
82-
if (typeof tui?.showToast === "function") {
83-
// Intentional: call via property access to preserve `this` binding on the SDK client.
84-
// The tui object is an SDK-generated client where methods live on the prototype.
85-
const tuiClient = tui as Record<string, (...args: unknown[]) => Promise<unknown>>;
86-
await tuiClient.showToast({
87-
body: {
88-
title: extractToastTitle(text),
89-
message: text.length > 200 ? `${text.slice(0, 200)}…` : text,
90-
variant: inferToastVariant(text),
91-
duration: 5000,
92-
},
93-
});
94-
return "sent";
95-
}
87+
const { pushNotification } = await import("../../shared/rpc-notifications");
88+
pushNotification(
89+
"toast",
90+
{
91+
title,
92+
message,
93+
variant: toastVariant,
94+
duration,
95+
},
96+
sessionId,
97+
);
98+
return "sent";
9699
} catch {
97-
// showToast failed or tui client is unavailable — fall through to ignored message.
98-
sessionLog(sessionId, "TUI showToast failed, falling back to ignored message");
100+
// RPC enqueue failed — fall through to ignored message.
101+
sessionLog(sessionId, "TUI RPC toast enqueue failed, falling back to ignored message");
99102
}
100103
}
101104
// Title-safety guard (issue #129): an ignored message is hidden from the
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/// <reference types="bun-types" />
2+
3+
import { beforeEach, describe, expect, it, mock } from "bun:test";
4+
5+
const createMagicContextHookMock = mock(() => ({ mocked: true }));
6+
7+
mock.module("../../hooks/magic-context", () => ({
8+
createMagicContextHook: createMagicContextHookMock,
9+
}));
10+
11+
describe("createSessionHooks", () => {
12+
beforeEach(() => {
13+
createMagicContextHookMock.mockClear();
14+
});
15+
16+
it("threads notification config into the magic-context hook", async () => {
17+
const { createSessionHooks } = await import("./create-session-hooks");
18+
19+
createSessionHooks({
20+
ctx: {
21+
client: {},
22+
directory: "/tmp/project",
23+
} as never,
24+
liveSessionState: {
25+
liveModelBySession: new Map(),
26+
variantBySession: new Map(),
27+
agentBySession: new Map(),
28+
},
29+
pluginConfig: {
30+
enabled: true,
31+
protected_tags: 10,
32+
cache_ttl: "5m",
33+
toast_duration_ms: 30_000,
34+
} as never,
35+
});
36+
37+
expect(createMagicContextHookMock).toHaveBeenCalledTimes(1);
38+
const args = createMagicContextHookMock.mock.calls[0]?.[0] as { config?: { toast_duration_ms?: number } };
39+
expect(args.config?.toast_duration_ms).toBe(30_000);
40+
});
41+
});

0 commit comments

Comments
 (0)