@cortexkit/opencode-magic-context 0.22.4 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agents/magic-context-prompt.d.ts +1 -1
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/agents/permissions.d.ts +4 -4
- package/dist/agents/permissions.d.ts.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +30 -0
- package/dist/config/project-security.d.ts.map +1 -0
- package/dist/config/prune-config-leaf.d.ts +27 -0
- package/dist/config/prune-config-leaf.d.ts.map +1 -0
- package/dist/config/schema/magic-context.d.ts +0 -13
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/config/variable.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts +9 -6
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
- package/dist/features/magic-context/key-files/read-stats.d.ts +0 -2
- package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
- package/dist/features/magic-context/memory/constants.d.ts +7 -0
- package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-ssrf.d.ts +29 -0
- package/dist/features/magic-context/memory/embedding-ssrf.d.ts.map +1 -0
- package/dist/features/magic-context/memory/project-identity.d.ts +10 -0
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/range-parser.d.ts +6 -0
- package/dist/features/magic-context/range-parser.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +124 -16
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +9 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +118 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +3 -3
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +12 -2
- package/dist/features/magic-context/tagger.d.ts.map +1 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +8 -0
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/semver.d.ts +17 -0
- package/dist/hooks/auto-update-checker/semver.d.ts.map +1 -0
- package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +22 -0
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -0
- package/dist/hooks/magic-context/command-handler.d.ts +1 -7
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +5 -0
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts +25 -0
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts +47 -2
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +117 -0
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -0
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
- package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts +36 -1
- package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +86 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts +6 -4
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-flush.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +10 -3
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts +3 -9
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +3 -5
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/note-visibility.d.ts +1 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts +132 -0
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts +55 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +91 -0
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +70 -0
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -0
- package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts +2 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +0 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +3 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/todo-view.d.ts +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +9 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +15 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +7 -10
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +14 -9
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/upgrade-reminder.d.ts +2 -1
- package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5682 -1281
- package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
- package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/tool-registry.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +4 -6
- package/dist/shared/announcement.d.ts.map +1 -1
- package/dist/shared/live-server-client.d.ts +50 -0
- package/dist/shared/live-server-client.d.ts.map +1 -0
- package/dist/shared/prompt-context.d.ts +31 -0
- package/dist/shared/prompt-context.d.ts.map +1 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +0 -3
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/safe-notification-target.d.ts +23 -0
- package/dist/shared/safe-notification-target.d.ts.map +1 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/transcript-opencode.d.ts.map +1 -1
- package/dist/shared/transcript.d.ts +15 -1
- package/dist/shared/transcript.d.ts.map +1 -1
- package/dist/tools/ctx-expand/constants.d.ts +1 -1
- package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
- package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
- package/dist/tools/ctx-memory/constants.d.ts +1 -1
- package/dist/tools/ctx-memory/constants.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-memory/types.d.ts +7 -3
- package/dist/tools/ctx-memory/types.d.ts.map +1 -1
- package/dist/tools/ctx-note/constants.d.ts +1 -1
- package/dist/tools/ctx-note/constants.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tools/ctx-note/types.d.ts +4 -0
- package/dist/tools/ctx-note/types.d.ts.map +1 -1
- package/dist/tools/ctx-search/constants.d.ts +1 -1
- package/dist/tools/ctx-search/constants.d.ts.map +1 -1
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/shared/announcement.test.ts +18 -0
- package/src/shared/announcement.ts +35 -20
- package/src/shared/live-server-client.ts +152 -0
- package/src/shared/prompt-context.ts +135 -0
- package/src/shared/rpc-server.ts +18 -2
- package/src/shared/rpc-types.ts +0 -3
- package/src/shared/safe-notification-target.test.ts +97 -0
- package/src/shared/safe-notification-target.ts +102 -0
- package/src/shared/tag-transcript.test.ts +34 -8
- package/src/shared/tag-transcript.ts +110 -8
- package/src/shared/transcript-opencode.ts +15 -5
- package/src/shared/transcript.ts +20 -2
- package/src/tui/data/context-db.ts +0 -3
- package/src/tui/index.tsx +11 -10
- package/src/tui/slots/sidebar-content.tsx +1 -26
- package/dist/hooks/magic-context/apply-context-nudge.d.ts +0 -5
- package/dist/hooks/magic-context/apply-context-nudge.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudge-bands.d.ts +0 -6
- package/dist/hooks/magic-context/nudge-bands.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudge-injection.d.ts +0 -7
- package/dist/hooks/magic-context/nudge-injection.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudge-placement-store.d.ts +0 -15
- package/dist/hooks/magic-context/nudge-placement-store.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudger.d.ts +0 -21
- package/dist/hooks/magic-context/nudger.d.ts.map +0 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-server client for the Channel 2 ctx_reduce ceiling nudge (a synthetic
|
|
3
|
+
* user `<system-reminder>` delivered via `promptAsync`).
|
|
4
|
+
*
|
|
5
|
+
* WHY a separate client instead of the plugin-provided `input.client`:
|
|
6
|
+
* OpenCode's plugin `input.client` routes through `Server.Default().app.fetch`,
|
|
7
|
+
* which uses a SEPARATE Effect `memoMap` from the live HTTP listener the UI
|
|
8
|
+
* uses. `SessionRunState` lives per-memoMap, so a plugin-origin `promptAsync`
|
|
9
|
+
* observes an "idle" runner while the live turn is still running, `ensureRunning`
|
|
10
|
+
* fails to coalesce, and OpenCode persists duplicate assistant children
|
|
11
|
+
* (upstream bug anomalyco/opencode#28202). Building a `createOpencodeClient`
|
|
12
|
+
* aimed at `input.serverUrl` via `globalThis.fetch` enters the SAME live
|
|
13
|
+
* listener, so `ensureRunning` sees the real run and coalesces — the synthetic
|
|
14
|
+
* message lands at the tail after the current assistant step.
|
|
15
|
+
*
|
|
16
|
+
* The live listener is only reachable on OpenCode Desktop (Electron+Node) and
|
|
17
|
+
* TUI launched with `--port 0`; plain TUI binds an internal listener that 404s
|
|
18
|
+
* `/session/*`. We probe once at init and cache per `serverUrl`. When
|
|
19
|
+
* unreachable, Channel 2 is DISABLED (Channel 1 + 85% force-materialization
|
|
20
|
+
* remain the backstop) — MC deliberately does NOT fall back to the in-process
|
|
21
|
+
* client because that would knowingly trigger #28202.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
25
|
+
|
|
26
|
+
export type LiveServerClient = ReturnType<typeof createOpencodeClient>;
|
|
27
|
+
|
|
28
|
+
const clientCache = new Map<string, LiveServerClient>();
|
|
29
|
+
|
|
30
|
+
function cacheKey(serverUrl: string, directory: string): string {
|
|
31
|
+
return `${serverUrl}|${directory}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeServerUrl(serverUrl: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(serverUrl).toString();
|
|
37
|
+
} catch {
|
|
38
|
+
return serverUrl;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Basic-auth header OpenCode expects when `OPENCODE_SERVER_PASSWORD` is set. */
|
|
43
|
+
function serverAuthHeaders(): Record<string, string> | undefined {
|
|
44
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
45
|
+
if (!password) return undefined;
|
|
46
|
+
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
|
|
47
|
+
return {
|
|
48
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cached `createOpencodeClient` aimed at the live HTTP listener for the given
|
|
54
|
+
* `(serverUrl, directory)`. One client is reused across deliveries.
|
|
55
|
+
*/
|
|
56
|
+
export function getLiveServerClient(serverUrl: string, directory: string): LiveServerClient {
|
|
57
|
+
const key = cacheKey(serverUrl, directory);
|
|
58
|
+
const cached = clientCache.get(key);
|
|
59
|
+
if (cached) return cached;
|
|
60
|
+
const client = createOpencodeClient({
|
|
61
|
+
baseUrl: serverUrl,
|
|
62
|
+
directory,
|
|
63
|
+
headers: serverAuthHeaders(),
|
|
64
|
+
fetch: globalThis.fetch,
|
|
65
|
+
});
|
|
66
|
+
clientCache.set(key, client);
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Per-serverUrl wake decision + probe TTL. One plugin process can host multiple
|
|
71
|
+
// OpenCode windows with different listener URLs, so the decision must be keyed.
|
|
72
|
+
interface ProbeDecision {
|
|
73
|
+
reachable: boolean;
|
|
74
|
+
probedAt: number;
|
|
75
|
+
}
|
|
76
|
+
const wakeDecisionByServerUrl = new Map<string, ProbeDecision>();
|
|
77
|
+
|
|
78
|
+
// Re-probe window: a transient 404/timeout shouldn't permanently disable
|
|
79
|
+
// Channel 2 for the whole session lifetime (per council-r3).
|
|
80
|
+
const PROBE_TTL_MS = 10 * 60_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Probe whether `serverUrl` serves OpenCode's HTTP API within `timeoutMs`.
|
|
84
|
+
* `true` only when `/session` proves the API is usable: any 2xx, or 401/403
|
|
85
|
+
* (auth-protected listener still exists). `false` for 404 (plain TUI internal
|
|
86
|
+
* listener), 5xx, connection refused, DNS failure, timeout, or malformed URL.
|
|
87
|
+
* Records the result + timestamp in the per-serverUrl cache.
|
|
88
|
+
*/
|
|
89
|
+
export async function probeServerReachable(
|
|
90
|
+
serverUrl: string | undefined,
|
|
91
|
+
timeoutMs = 1500,
|
|
92
|
+
): Promise<boolean> {
|
|
93
|
+
if (!serverUrl) return false;
|
|
94
|
+
const normalized = normalizeServerUrl(serverUrl);
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
97
|
+
let reachable = false;
|
|
98
|
+
try {
|
|
99
|
+
const probeUrl = new URL("/session", serverUrl).toString();
|
|
100
|
+
const res = await globalThis.fetch(probeUrl, {
|
|
101
|
+
method: "GET",
|
|
102
|
+
headers: serverAuthHeaders(),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
reachable = res.ok || res.status === 401 || res.status === 403;
|
|
106
|
+
} catch {
|
|
107
|
+
reachable = false;
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
wakeDecisionByServerUrl.set(normalized, { reachable, probedAt: Date.now() });
|
|
111
|
+
}
|
|
112
|
+
return reachable;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Record a probe result directly (test helper / explicit override). */
|
|
116
|
+
export function setLiveServerWakeAvailable(
|
|
117
|
+
serverUrl: string | undefined,
|
|
118
|
+
available: boolean,
|
|
119
|
+
): void {
|
|
120
|
+
if (!serverUrl) return;
|
|
121
|
+
wakeDecisionByServerUrl.set(normalizeServerUrl(serverUrl), {
|
|
122
|
+
reachable: available,
|
|
123
|
+
probedAt: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Should Channel 2 deliver through the live-server client for `serverUrl`?
|
|
129
|
+
* Returns false when never probed or the last probe failed. A stale decision
|
|
130
|
+
* (older than the TTL) returns false so the caller re-probes before delivering.
|
|
131
|
+
*/
|
|
132
|
+
export function useLiveServerWake(serverUrl?: string): boolean {
|
|
133
|
+
if (!serverUrl) return false;
|
|
134
|
+
const decision = wakeDecisionByServerUrl.get(normalizeServerUrl(serverUrl));
|
|
135
|
+
if (!decision) return false;
|
|
136
|
+
if (Date.now() - decision.probedAt > PROBE_TTL_MS) return false;
|
|
137
|
+
return decision.reachable;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when a usable (non-stale) probe decision exists, regardless of outcome. */
|
|
141
|
+
export function hasFreshProbe(serverUrl?: string): boolean {
|
|
142
|
+
if (!serverUrl) return false;
|
|
143
|
+
const decision = wakeDecisionByServerUrl.get(normalizeServerUrl(serverUrl));
|
|
144
|
+
if (!decision) return false;
|
|
145
|
+
return Date.now() - decision.probedAt <= PROBE_TTL_MS;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Test helper — reset both caches between cases. */
|
|
149
|
+
export function __resetLiveServerClientForTests(): void {
|
|
150
|
+
clientCache.clear();
|
|
151
|
+
wakeDecisionByServerUrl.clear();
|
|
152
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the newest effective prompt context (agent + model + variant) for a
|
|
3
|
+
* session by reading recent messages from the OpenCode HTTP API.
|
|
4
|
+
*
|
|
5
|
+
* WHY: a Channel 2 ceiling nudge sends a synthetic user message via
|
|
6
|
+
* `promptAsync` with `noReply:false` (it DOES trigger an assistant turn).
|
|
7
|
+
* OpenCode's `createUserMessage` resolves variant relative to the chosen
|
|
8
|
+
* agent; passing model alone makes OpenCode pick the default agent whose model
|
|
9
|
+
* check then fails, bypassing the active variant and busting the provider
|
|
10
|
+
* prefix cache the prior turn warmed. So we pass agent + model + variant
|
|
11
|
+
* explicitly, mirroring the resolution AFT/opencode-xtra use for their wake
|
|
12
|
+
* notifications.
|
|
13
|
+
*
|
|
14
|
+
* Walk newest→oldest and merge field-by-field so the newest context-bearing
|
|
15
|
+
* message wins while older messages only fill fields it did not provide. Read
|
|
16
|
+
* BOTH the flat shape (`info.providerID`) used by AssistantMessage and the
|
|
17
|
+
* nested shape (`info.model.providerID`) used by UserMessage.
|
|
18
|
+
*
|
|
19
|
+
* Bounded via `query.limit` — the legacy `/session/{id}/message` endpoint
|
|
20
|
+
* hydrates the ENTIRE session without it (30k-45k messages on large sessions).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ResolvedPromptContext {
|
|
24
|
+
agent?: string;
|
|
25
|
+
model?: { providerID: string; modelID: string };
|
|
26
|
+
variant?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RawInfo {
|
|
30
|
+
role?: string;
|
|
31
|
+
agent?: string;
|
|
32
|
+
variant?: string;
|
|
33
|
+
providerID?: string;
|
|
34
|
+
modelID?: string;
|
|
35
|
+
model?: { providerID?: string; modelID?: string; variant?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractMessages(response: unknown): unknown[] {
|
|
43
|
+
if (Array.isArray(response)) return response;
|
|
44
|
+
if (isRecord(response) && Array.isArray(response.data)) return response.data;
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractFromMessage(message: unknown): ResolvedPromptContext | null {
|
|
49
|
+
if (!isRecord(message) || !isRecord(message.info)) return null;
|
|
50
|
+
const info = message.info as RawInfo;
|
|
51
|
+
const modelInfo = isRecord(info.model) ? info.model : undefined;
|
|
52
|
+
|
|
53
|
+
const agent = typeof info.agent === "string" ? info.agent : undefined;
|
|
54
|
+
const providerID =
|
|
55
|
+
typeof modelInfo?.providerID === "string"
|
|
56
|
+
? modelInfo.providerID
|
|
57
|
+
: typeof info.providerID === "string"
|
|
58
|
+
? info.providerID
|
|
59
|
+
: undefined;
|
|
60
|
+
const modelID =
|
|
61
|
+
typeof modelInfo?.modelID === "string"
|
|
62
|
+
? modelInfo.modelID
|
|
63
|
+
: typeof info.modelID === "string"
|
|
64
|
+
? info.modelID
|
|
65
|
+
: undefined;
|
|
66
|
+
const variant =
|
|
67
|
+
typeof modelInfo?.variant === "string"
|
|
68
|
+
? modelInfo.variant
|
|
69
|
+
: typeof info.variant === "string"
|
|
70
|
+
? info.variant
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
if (!agent && (!providerID || !modelID) && !variant) return null;
|
|
74
|
+
const out: ResolvedPromptContext = {};
|
|
75
|
+
if (agent) out.agent = agent;
|
|
76
|
+
if (providerID && modelID) out.model = { providerID, modelID };
|
|
77
|
+
if (variant) out.variant = variant;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mergeContexts(
|
|
82
|
+
base: ResolvedPromptContext,
|
|
83
|
+
patch: ResolvedPromptContext,
|
|
84
|
+
): ResolvedPromptContext {
|
|
85
|
+
return {
|
|
86
|
+
agent: base.agent ?? patch.agent,
|
|
87
|
+
model: base.model ?? patch.model,
|
|
88
|
+
variant: base.variant ?? patch.variant,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isComplete(ctx: ResolvedPromptContext): boolean {
|
|
93
|
+
return Boolean(ctx.agent && ctx.model && ctx.variant);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PROMPT_CONTEXT_MESSAGE_LIMIT = 50;
|
|
97
|
+
|
|
98
|
+
export async function resolvePromptContext(
|
|
99
|
+
client: unknown,
|
|
100
|
+
sessionId: string,
|
|
101
|
+
): Promise<ResolvedPromptContext | null> {
|
|
102
|
+
if (!client || !sessionId) return null;
|
|
103
|
+
const c = client as {
|
|
104
|
+
session?: {
|
|
105
|
+
messages?: (input: {
|
|
106
|
+
path: { id: string };
|
|
107
|
+
query?: { limit?: number };
|
|
108
|
+
}) => Promise<{ data?: unknown[] } | unknown[]>;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
if (typeof c.session?.messages !== "function") return null;
|
|
112
|
+
|
|
113
|
+
let messages: unknown[] = [];
|
|
114
|
+
try {
|
|
115
|
+
const response = await c.session.messages({
|
|
116
|
+
path: { id: sessionId },
|
|
117
|
+
query: { limit: PROMPT_CONTEXT_MESSAGE_LIMIT },
|
|
118
|
+
});
|
|
119
|
+
messages = extractMessages(response);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (messages.length === 0) return null;
|
|
124
|
+
|
|
125
|
+
let result: ResolvedPromptContext = {};
|
|
126
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
127
|
+
const ctx = extractFromMessage(messages[i]);
|
|
128
|
+
if (!ctx) continue;
|
|
129
|
+
result = mergeContexts(result, ctx);
|
|
130
|
+
if (isComplete(result)) return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!result.agent && !result.model && !result.variant) return null;
|
|
134
|
+
return result;
|
|
135
|
+
}
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
mkdirSync,
|
|
4
4
|
readdirSync,
|
|
@@ -14,6 +14,19 @@ import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc
|
|
|
14
14
|
|
|
15
15
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Constant-time bearer-token comparison. `timingSafeEqual` throws on
|
|
19
|
+
* length-mismatched buffers, so guard on length first (the length itself is not
|
|
20
|
+
* secret — the token bytes are). Avoids leaking the token via response-timing on
|
|
21
|
+
* the loopback auth check.
|
|
22
|
+
*/
|
|
23
|
+
function tokensMatch(presented: string, expected: string): boolean {
|
|
24
|
+
const a = Buffer.from(presented, "utf8");
|
|
25
|
+
const b = Buffer.from(expected, "utf8");
|
|
26
|
+
if (a.length !== b.length) return false;
|
|
27
|
+
return timingSafeEqual(a, b);
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export class MagicContextRpcServer {
|
|
18
31
|
private server: Server | null = null;
|
|
19
32
|
private port = 0;
|
|
@@ -149,9 +162,12 @@ export class MagicContextRpcServer {
|
|
|
149
162
|
// Require the per-process bearer token on every side-effecting call.
|
|
150
163
|
// The legitimate TUI client reads it from the same port file it used to
|
|
151
164
|
// discover the port; a process that only guessed the port cannot.
|
|
165
|
+
// Constant-time compare so a local attacker can't byte-probe the token
|
|
166
|
+
// via response-timing (length-guard first, since timingSafeEqual throws
|
|
167
|
+
// on length mismatch).
|
|
152
168
|
const auth = req.headers.authorization;
|
|
153
169
|
const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
154
|
-
if (presented
|
|
170
|
+
if (!tokensMatch(presented, this.token)) {
|
|
155
171
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
156
172
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
157
173
|
req.resume();
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -97,7 +97,6 @@ export interface StatusDetail extends SidebarSnapshot {
|
|
|
97
97
|
activeBytes: number;
|
|
98
98
|
lastResponseTime: number;
|
|
99
99
|
lastNudgeTokens: number;
|
|
100
|
-
lastNudgeBand: string;
|
|
101
100
|
lastTransformError: string | null;
|
|
102
101
|
isSubagent: boolean;
|
|
103
102
|
pendingOps: Array<{ tagId: number; operation: string }>;
|
|
@@ -118,9 +117,7 @@ export interface StatusDetail extends SidebarSnapshot {
|
|
|
118
117
|
*/
|
|
119
118
|
executeThresholdTokens?: number;
|
|
120
119
|
protectedTagCount: number;
|
|
121
|
-
nudgeInterval: number;
|
|
122
120
|
historyBudgetPercentage: number;
|
|
123
|
-
nextNudgeAfter: number;
|
|
124
121
|
historyBlockTokens: number;
|
|
125
122
|
compressionBudget: number | null;
|
|
126
123
|
compressionUsage: string | null;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { isDefaultSessionTitle, waitForSafeNotificationTarget } from "./safe-notification-target";
|
|
3
|
+
|
|
4
|
+
function clientWithTitle(title: string | undefined, calls?: { count: number }) {
|
|
5
|
+
return {
|
|
6
|
+
session: {
|
|
7
|
+
get: async (_input: unknown) => {
|
|
8
|
+
if (calls) calls.count += 1;
|
|
9
|
+
return { data: { title } };
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("isDefaultSessionTitle", () => {
|
|
16
|
+
it("matches OpenCode default titles for parent and child sessions", () => {
|
|
17
|
+
expect(isDefaultSessionTitle("New session - 2026-06-10T15:33:11.538Z")).toBe(true);
|
|
18
|
+
expect(isDefaultSessionTitle("Child session - 2026-01-02T03:04:05.678Z")).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not match real titles", () => {
|
|
22
|
+
expect(isDefaultSessionTitle("Quick test")).toBe(false);
|
|
23
|
+
expect(isDefaultSessionTitle("New session - notes")).toBe(false);
|
|
24
|
+
// Prefix alone isn't enough — the timestamp must match exactly,
|
|
25
|
+
// mirroring OpenCode's Session.isDefaultTitle.
|
|
26
|
+
expect(isDefaultSessionTitle("New session - 2026-06-10")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("waitForSafeNotificationTarget", () => {
|
|
31
|
+
it("returns safe immediately for a titled session", async () => {
|
|
32
|
+
const calls = { count: 0 };
|
|
33
|
+
const result = await waitForSafeNotificationTarget(
|
|
34
|
+
clientWithTitle("Fix tagger collision", calls),
|
|
35
|
+
"ses-titled",
|
|
36
|
+
{ attempts: 4, delayMs: 1 },
|
|
37
|
+
);
|
|
38
|
+
expect(result).toBe("safe");
|
|
39
|
+
expect(calls.count).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns skip after exhausting attempts on a default-titled session", async () => {
|
|
43
|
+
const calls = { count: 0 };
|
|
44
|
+
const result = await waitForSafeNotificationTarget(
|
|
45
|
+
clientWithTitle("New session - 2026-06-10T15:33:11.538Z", calls),
|
|
46
|
+
"ses-fresh",
|
|
47
|
+
{ attempts: 3, delayMs: 1 },
|
|
48
|
+
);
|
|
49
|
+
expect(result).toBe("skip");
|
|
50
|
+
expect(calls.count).toBe(3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns safe once the title flips to a real one mid-retry", async () => {
|
|
54
|
+
let call = 0;
|
|
55
|
+
const client = {
|
|
56
|
+
session: {
|
|
57
|
+
get: async () => {
|
|
58
|
+
call += 1;
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
title: call < 2 ? "New session - 2026-06-10T15:33:11.538Z" : "Greeting",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const result = await waitForSafeNotificationTarget(client, "ses-flip", {
|
|
68
|
+
attempts: 4,
|
|
69
|
+
delayMs: 1,
|
|
70
|
+
});
|
|
71
|
+
expect(result).toBe("safe");
|
|
72
|
+
expect(call).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fails open when the client cannot report a title", async () => {
|
|
76
|
+
expect(
|
|
77
|
+
await waitForSafeNotificationTarget({}, "ses-no-api", { attempts: 2, delayMs: 1 }),
|
|
78
|
+
).toBe("safe");
|
|
79
|
+
const throwing = {
|
|
80
|
+
session: {
|
|
81
|
+
get: async () => {
|
|
82
|
+
throw new Error("transport down");
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
expect(
|
|
87
|
+
await waitForSafeNotificationTarget(throwing, "ses-throw", { attempts: 2, delayMs: 1 }),
|
|
88
|
+
).toBe("safe");
|
|
89
|
+
// Direct-shape response (no `.data` wrapper) is also recognized.
|
|
90
|
+
const direct = {
|
|
91
|
+
session: { get: async () => ({ title: "Real title" }) },
|
|
92
|
+
};
|
|
93
|
+
expect(
|
|
94
|
+
await waitForSafeNotificationTarget(direct, "ses-direct", { attempts: 2, delayMs: 1 }),
|
|
95
|
+
).toBe("safe");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { log } from "./logger";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Guard for ignored-message notification posting: only post into sessions
|
|
5
|
+
* that already carry a REAL title.
|
|
6
|
+
*
|
|
7
|
+
* Why: OpenCode's title generation (SessionPrompt.ensureTitle) silently and
|
|
8
|
+
* PERMANENTLY skips a session once it contains more than one non-synthetic
|
|
9
|
+
* user message. Our notification posts (config warnings, conflict warnings,
|
|
10
|
+
* schema-fence warnings, startup announcements) are `ignored: true` — hidden
|
|
11
|
+
* from the LLM — but NOT `synthetic: true`, so the title gate counts them as
|
|
12
|
+
* real user messages. A notification landing in a fresh session before the
|
|
13
|
+
* user's first prompt therefore suppressed that session's title forever
|
|
14
|
+
* (issue #129; only repros where the new session is the project's only one,
|
|
15
|
+
* e.g. fresh non-git directories).
|
|
16
|
+
*
|
|
17
|
+
* We deliberately do NOT mark our posts `synthetic: true` instead: the
|
|
18
|
+
* Desktop renderer (UserMessageDisplay) picks the first NON-synthetic text
|
|
19
|
+
* part, so synthetic would blank the message on Desktop — the only surface
|
|
20
|
+
* these posts exist for.
|
|
21
|
+
*
|
|
22
|
+
* ensureTitle short-circuits on `!isDefaultTitle(session.title)` before the
|
|
23
|
+
* message count, so posting into an already-titled session can never affect
|
|
24
|
+
* titling. Callers must NOT mark a notification as delivered when this guard
|
|
25
|
+
* returns "skip", so the next startup retries.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mirrors OpenCode's Session.isDefaultTitle (session.ts): a default title is
|
|
30
|
+
* `New session - <ISO>` or `Child session - <ISO>`.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_TITLE_RE =
|
|
33
|
+
/^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
34
|
+
|
|
35
|
+
export function isDefaultSessionTitle(title: string): boolean {
|
|
36
|
+
return DEFAULT_TITLE_RE.test(title);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read a session's current title via the SDK client. Returns null when the
|
|
41
|
+
* title cannot be determined (missing API, transport error, unexpected
|
|
42
|
+
* shape) — callers treat that as "fail open" and post, preserving delivery
|
|
43
|
+
* on clients/tests that don't expose session.get.
|
|
44
|
+
*/
|
|
45
|
+
async function readSessionTitle(client: unknown, sessionId: string): Promise<string | null> {
|
|
46
|
+
try {
|
|
47
|
+
const c = client as {
|
|
48
|
+
session?: { get?: (input: unknown) => unknown };
|
|
49
|
+
};
|
|
50
|
+
if (typeof c.session?.get !== "function") return null;
|
|
51
|
+
const raw = await Promise.resolve(c.session.get({ path: { id: sessionId } }));
|
|
52
|
+
// SDK response shapes vary across versions: `{ data: { title } }` or
|
|
53
|
+
// the session object directly.
|
|
54
|
+
const obj = raw as { data?: { title?: unknown }; title?: unknown } | null;
|
|
55
|
+
const title = obj && typeof obj === "object" ? (obj.data?.title ?? obj.title) : undefined;
|
|
56
|
+
return typeof title === "string" ? title : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SafeTargetOptions {
|
|
63
|
+
/** Total title checks before giving up (default 4). */
|
|
64
|
+
attempts?: number;
|
|
65
|
+
/** Delay between checks in ms (default 15s). */
|
|
66
|
+
delayMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve whether `sessionId` is safe to receive an ignored-message post.
|
|
71
|
+
*
|
|
72
|
+
* - "safe": the session has a real (non-default) title, or the title is
|
|
73
|
+
* unreadable (fail-open).
|
|
74
|
+
* - "skip": the session still has OpenCode's default title after all
|
|
75
|
+
* attempts — posting now could permanently suppress its title generation.
|
|
76
|
+
* The caller must leave its delivered/seen marker unset so a later
|
|
77
|
+
* startup retries.
|
|
78
|
+
*
|
|
79
|
+
* The retry window exists for the common startup case: plugin init fires a
|
|
80
|
+
* few seconds after launch, the user prompts shortly after, and the title
|
|
81
|
+
* lands within seconds of that first prompt.
|
|
82
|
+
*/
|
|
83
|
+
export async function waitForSafeNotificationTarget(
|
|
84
|
+
client: unknown,
|
|
85
|
+
sessionId: string,
|
|
86
|
+
options?: SafeTargetOptions,
|
|
87
|
+
): Promise<"safe" | "skip"> {
|
|
88
|
+
const attempts = Math.max(1, options?.attempts ?? 4);
|
|
89
|
+
const delayMs = options?.delayMs ?? 15_000;
|
|
90
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
91
|
+
const title = await readSessionTitle(client, sessionId);
|
|
92
|
+
if (title === null) return "safe";
|
|
93
|
+
if (!isDefaultSessionTitle(title)) return "safe";
|
|
94
|
+
if (attempt < attempts - 1) {
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
log(
|
|
99
|
+
`[magic-context] notification skipped: session ${sessionId} still has its default title (would suppress title generation); will retry on a later startup`,
|
|
100
|
+
);
|
|
101
|
+
return "skip";
|
|
102
|
+
}
|
|
@@ -159,7 +159,12 @@ describe("tagTranscript tool aggregation", () => {
|
|
|
159
159
|
commit() {},
|
|
160
160
|
};
|
|
161
161
|
|
|
162
|
-
const { targets } = tagTranscript(
|
|
162
|
+
const { targets } = tagTranscript(
|
|
163
|
+
"session-1",
|
|
164
|
+
transcript,
|
|
165
|
+
tagger,
|
|
166
|
+
new FakeDb() as unknown as ContextDatabase,
|
|
167
|
+
);
|
|
163
168
|
|
|
164
169
|
const firstTag = tagger.getToolTag("session-1", "read:32", "assistant-1");
|
|
165
170
|
const secondTag = tagger.getToolTag("session-1", "read:32", "assistant-2");
|
|
@@ -185,7 +190,12 @@ describe("tagTranscript tool aggregation", () => {
|
|
|
185
190
|
commit() {},
|
|
186
191
|
};
|
|
187
192
|
|
|
188
|
-
const { targets } = tagTranscript(
|
|
193
|
+
const { targets } = tagTranscript(
|
|
194
|
+
"session-1",
|
|
195
|
+
transcript,
|
|
196
|
+
tagger,
|
|
197
|
+
new FakeDb() as unknown as ContextDatabase,
|
|
198
|
+
);
|
|
189
199
|
const tag = tagger.getToolTag("session-1", "read:99", "assistant-1");
|
|
190
200
|
|
|
191
201
|
let result: "truncated" | "absent" | undefined;
|
|
@@ -210,7 +220,12 @@ describe("tagTranscript tool aggregation", () => {
|
|
|
210
220
|
commit() {},
|
|
211
221
|
};
|
|
212
222
|
|
|
213
|
-
const { targets } = tagTranscript(
|
|
223
|
+
const { targets } = tagTranscript(
|
|
224
|
+
"session-1",
|
|
225
|
+
transcript,
|
|
226
|
+
tagger,
|
|
227
|
+
new FakeDb() as unknown as ContextDatabase,
|
|
228
|
+
);
|
|
214
229
|
const tag = tagger.getToolTag("session-1", "read:multi", "assistant-1");
|
|
215
230
|
|
|
216
231
|
expect(targets.size).toBe(1);
|
|
@@ -235,7 +250,12 @@ describe("tagTranscript tool aggregation", () => {
|
|
|
235
250
|
commit() {},
|
|
236
251
|
};
|
|
237
252
|
|
|
238
|
-
const { targets } = tagTranscript(
|
|
253
|
+
const { targets } = tagTranscript(
|
|
254
|
+
"session-1",
|
|
255
|
+
transcript,
|
|
256
|
+
tagger,
|
|
257
|
+
new FakeDb() as unknown as ContextDatabase,
|
|
258
|
+
);
|
|
239
259
|
|
|
240
260
|
const olderTag = tagger.getToolTag("session-1", "read:reused", "assistant-old");
|
|
241
261
|
const nearestTag = tagger.getToolTag("session-1", "read:reused", "assistant-near");
|
|
@@ -272,9 +292,15 @@ describe("tagTranscript tool aggregation", () => {
|
|
|
272
292
|
|
|
273
293
|
const tag = tagger.getToolTag("session-1", "read:image", "assistant-1");
|
|
274
294
|
expect(tag).toBeDefined();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
// byte_size is OUTPUT-only: the tag reserves at 0 on the tool_use
|
|
296
|
+
// occurrence (args live in inputByteSize), then updates to the real
|
|
297
|
+
// result payload size when the tool_result (incl. the non-text image
|
|
298
|
+
// block) is seen — proving non-text content is byte-accounted.
|
|
299
|
+
expect(tagger.byteSizes.get(tag ?? -1)).toBe(0);
|
|
300
|
+
// Two tool_result blocks (caption text + image) under one callId; the
|
|
301
|
+
// tag byte_size climbs to the LARGEST output block (the image > 512B).
|
|
302
|
+
const updatesForTag = db.byteSizeUpdates.filter((u) => u.tagNumber === tag);
|
|
303
|
+
expect(updatesForTag.length).toBeGreaterThanOrEqual(1);
|
|
304
|
+
expect(Math.max(...updatesForTag.map((u) => u.byteSize))).toBeGreaterThan(512);
|
|
279
305
|
});
|
|
280
306
|
});
|