@cortexkit/opencode-magic-context 0.31.5 → 0.32.1
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 +3 -3
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +2 -0
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +9 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
- package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts +6 -2
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +7 -4
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts +8 -16
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
- package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
- package/dist/features/magic-context/storage-clone.d.ts +34 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -0
- 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 +8 -5
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +11 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +2 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +10 -0
- package/dist/features/magic-context/tagger.d.ts.map +1 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
- package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +24 -0
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts +7 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-render.d.ts +2 -0
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +3 -0
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +8 -9
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts +5 -4
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts +2 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts +32 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/shadow-sender.d.ts +300 -0
- package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -0
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/temporal-awareness.d.ts +3 -3
- package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
- package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +32 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5418 -972
- package/dist/plugin/hooks/create-session-hooks.d.ts +48 -175
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts +3 -0
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +5 -0
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +3 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +11 -6
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +2 -2
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts +18 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/tui-config.d.ts +5 -3
- package/dist/shared/tui-config.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/tui/data/context-db.d.ts +9 -2
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +4 -5
- package/dist/tui/data/notification-socket.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts +9 -2
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
- package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/shared/models-dev-cache.test.ts +5 -0
- package/src/shared/models-dev-cache.ts +44 -6
- package/src/shared/opencode-compaction-detector.test.ts +11 -2
- package/src/shared/rpc-client.test.ts +213 -9
- package/src/shared/rpc-client.ts +48 -39
- package/src/shared/rpc-notifications.test.ts +26 -5
- package/src/shared/rpc-notifications.ts +44 -35
- package/src/shared/rpc-server.ts +77 -33
- package/src/shared/sqlite.ts +22 -3
- package/src/shared/tag-transcript.test.ts +253 -7
- package/src/shared/tag-transcript.ts +422 -114
- package/src/shared/tui-config.test.ts +4 -12
- package/src/shared/tui-config.ts +6 -6
- package/src/tui/data/context-db.test.ts +113 -0
- package/src/tui/data/context-db.ts +26 -19
- package/src/tui/data/notification-socket.test.ts +277 -11
- package/src/tui/data/notification-socket.ts +257 -86
- package/src/tui/index.tsx +33 -25
- package/src/tui/slots/sidebar-content.tsx +62 -24
- package/src/tui-compiled/data/context-db.ts +26 -19
- package/src/tui-compiled/data/notification-socket.ts +257 -86
- package/src/tui-compiled/index.tsx +41 -24
- package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
|
@@ -4,11 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
const roots: string[] = [];
|
|
7
|
-
const prevConfigDir = process.env.OPENCODE_CONFIG_DIR;
|
|
8
|
-
|
|
9
7
|
afterEach(() => {
|
|
10
|
-
if (prevConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR;
|
|
11
|
-
else process.env.OPENCODE_CONFIG_DIR = prevConfigDir;
|
|
12
8
|
for (const root of roots.splice(0)) {
|
|
13
9
|
rmSync(root, { recursive: true, force: true });
|
|
14
10
|
}
|
|
@@ -18,7 +14,6 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
18
14
|
it("preserves tuple dev-path plugin entry and does not add @latest", async () => {
|
|
19
15
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-"));
|
|
20
16
|
roots.push(root);
|
|
21
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
22
17
|
const devPath = "/Work/magic-context/packages/plugin";
|
|
23
18
|
const tuiPath = join(root, "tui.json");
|
|
24
19
|
writeFileSync(
|
|
@@ -27,7 +22,7 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
27
22
|
);
|
|
28
23
|
|
|
29
24
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
30
|
-
const changed = ensureTuiPluginEntry();
|
|
25
|
+
const changed = ensureTuiPluginEntry({ configDir: root });
|
|
31
26
|
expect(changed).toBe(false);
|
|
32
27
|
const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
|
|
33
28
|
expect(parsed.plugin).toHaveLength(2);
|
|
@@ -40,7 +35,6 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
40
35
|
it("upgrades bare npm name to @latest while preserving tuple options", async () => {
|
|
41
36
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-npm-"));
|
|
42
37
|
roots.push(root);
|
|
43
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
44
38
|
const tuiPath = join(root, "tui.json");
|
|
45
39
|
writeFileSync(
|
|
46
40
|
tuiPath,
|
|
@@ -54,7 +48,7 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
54
48
|
);
|
|
55
49
|
|
|
56
50
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
57
|
-
expect(ensureTuiPluginEntry()).toBe(true);
|
|
51
|
+
expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
|
|
58
52
|
const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
|
|
59
53
|
const entry = parsed.plugin[0] as unknown[];
|
|
60
54
|
expect(entry[0]).toBe("@cortexkit/opencode-magic-context@latest");
|
|
@@ -64,10 +58,9 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
64
58
|
it("creates tui.jsonc (not tui.json) on a fresh install", async () => {
|
|
65
59
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-fresh-"));
|
|
66
60
|
roots.push(root);
|
|
67
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
68
61
|
|
|
69
62
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
70
|
-
expect(ensureTuiPluginEntry()).toBe(true);
|
|
63
|
+
expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
|
|
71
64
|
|
|
72
65
|
// The new file must be tui.jsonc so a tui.json stub never ends up
|
|
73
66
|
// sitting next to a tui.jsonc the user writes later (#176).
|
|
@@ -82,7 +75,6 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
82
75
|
it("writes into the existing tui.jsonc when both files exist", async () => {
|
|
83
76
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-both-"));
|
|
84
77
|
roots.push(root);
|
|
85
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
86
78
|
// A real user config in tui.jsonc plus a leftover empty tui.json.
|
|
87
79
|
writeFileSync(
|
|
88
80
|
join(root, "tui.jsonc"),
|
|
@@ -91,7 +83,7 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
91
83
|
writeFileSync(join(root, "tui.json"), "{}\n");
|
|
92
84
|
|
|
93
85
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
94
|
-
expect(ensureTuiPluginEntry()).toBe(true);
|
|
86
|
+
expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
|
|
95
87
|
|
|
96
88
|
// The plugin entry must land in tui.jsonc (higher precedence), and the
|
|
97
89
|
// user's keybinds must survive; tui.json must be left untouched.
|
package/src/shared/tui-config.ts
CHANGED
|
@@ -62,8 +62,8 @@ function writeTuiConfigAtomic(configPath: string, config: Record<string, unknown
|
|
|
62
62
|
renameSync(tmpPath, configPath);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function resolveTuiConfigPath(): string {
|
|
66
|
-
const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
|
|
65
|
+
function resolveTuiConfigPath(configDirOverride?: string): string {
|
|
66
|
+
const configDir = configDirOverride ?? getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
|
|
67
67
|
const jsoncPath = join(configDir, "tui.jsonc");
|
|
68
68
|
const jsonPath = join(configDir, "tui.json");
|
|
69
69
|
|
|
@@ -80,12 +80,12 @@ function resolveTuiConfigPath(): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Ensure
|
|
84
|
-
* Creates tui.
|
|
83
|
+
* Ensure the selected TUI config has the magic-context plugin entry.
|
|
84
|
+
* Creates tui.jsonc if neither TUI config exists. Silently skips if already present.
|
|
85
85
|
*/
|
|
86
|
-
export function ensureTuiPluginEntry(): boolean {
|
|
86
|
+
export function ensureTuiPluginEntry(options: { configDir?: string } = {}): boolean {
|
|
87
87
|
try {
|
|
88
|
-
const configPath = resolveTuiConfigPath();
|
|
88
|
+
const configPath = resolveTuiConfigPath(options.configDir);
|
|
89
89
|
|
|
90
90
|
let config: Record<string, unknown> = {};
|
|
91
91
|
if (existsSync(configPath)) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { MagicContextRpcServer } from "../../shared/rpc-server";
|
|
6
|
+
import type { SidebarSnapshot } from "../../shared/rpc-types";
|
|
7
|
+
import { closeRpc, getCompartmentCount, initRpcClient, loadSidebarSnapshot } from "./context-db";
|
|
8
|
+
|
|
9
|
+
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
|
10
|
+
const tempDirs: string[] = [];
|
|
11
|
+
const servers: MagicContextRpcServer[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
closeRpc();
|
|
15
|
+
for (const server of servers.splice(0)) server.stop();
|
|
16
|
+
for (const dir of tempDirs.splice(0)) {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
18
|
+
}
|
|
19
|
+
if (originalXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
20
|
+
else process.env.XDG_DATA_HOME = originalXdgDataHome;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeDataHome(): string {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "mc-context-db-"));
|
|
25
|
+
tempDirs.push(dir);
|
|
26
|
+
process.env.XDG_DATA_HOME = dir;
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function snapshot(sessionId: string, inputTokens: number): SidebarSnapshot {
|
|
31
|
+
return {
|
|
32
|
+
sessionId,
|
|
33
|
+
usagePercentage: inputTokens > 0 ? 25 : 0,
|
|
34
|
+
inputTokens,
|
|
35
|
+
contextLimit: 1000,
|
|
36
|
+
systemPromptTokens: 0,
|
|
37
|
+
compartmentCount: inputTokens > 0 ? 2 : 0,
|
|
38
|
+
memoryCount: 0,
|
|
39
|
+
memoryBlockCount: 0,
|
|
40
|
+
pendingOpsCount: 0,
|
|
41
|
+
historianRunning: false,
|
|
42
|
+
compartmentInProgress: false,
|
|
43
|
+
sessionNoteCount: 0,
|
|
44
|
+
readySmartNoteCount: 0,
|
|
45
|
+
cacheTtl: "5m",
|
|
46
|
+
lastDreamerRunAt: null,
|
|
47
|
+
projectIdentity: null,
|
|
48
|
+
compartmentTokens: 0,
|
|
49
|
+
factTokens: 0,
|
|
50
|
+
memoryTokens: 0,
|
|
51
|
+
docsTokens: 0,
|
|
52
|
+
profileTokens: 0,
|
|
53
|
+
conversationTokens: inputTokens,
|
|
54
|
+
toolCallTokens: 0,
|
|
55
|
+
toolDefinitionTokens: 0,
|
|
56
|
+
executeThreshold: 65,
|
|
57
|
+
newWorkTokens: null,
|
|
58
|
+
totalInputTokens: inputTokens,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function startServer(
|
|
63
|
+
dataHome: string,
|
|
64
|
+
directory: string,
|
|
65
|
+
sidebar: () => Record<string, unknown>,
|
|
66
|
+
): Promise<MagicContextRpcServer> {
|
|
67
|
+
const server = new MagicContextRpcServer(
|
|
68
|
+
join(dataHome, "cortexkit", "magic-context"),
|
|
69
|
+
directory,
|
|
70
|
+
);
|
|
71
|
+
server.handle("sidebar-snapshot", async () => sidebar());
|
|
72
|
+
await server.start();
|
|
73
|
+
servers.push(server);
|
|
74
|
+
return server;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("TUI context RPC data", () => {
|
|
78
|
+
test("uses an unexpired sticky snapshot only for failed responses", async () => {
|
|
79
|
+
const dataHome = makeDataHome();
|
|
80
|
+
const directory = "/repo-sticky";
|
|
81
|
+
const sessionId = "ses_sticky";
|
|
82
|
+
let response: Record<string, unknown> = snapshot(sessionId, 250) as unknown as Record<
|
|
83
|
+
string,
|
|
84
|
+
unknown
|
|
85
|
+
>;
|
|
86
|
+
await startServer(dataHome, directory, () => response);
|
|
87
|
+
initRpcClient(directory);
|
|
88
|
+
|
|
89
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250);
|
|
90
|
+
response = { error: "database busy" };
|
|
91
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250);
|
|
92
|
+
|
|
93
|
+
response = snapshot(sessionId, 0) as unknown as Record<string, unknown>;
|
|
94
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(0);
|
|
95
|
+
response = { error: "database busy again" };
|
|
96
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("distinguishes a real zero compartment count from an RPC failure", async () => {
|
|
100
|
+
const dataHome = makeDataHome();
|
|
101
|
+
const directory = "/repo-count";
|
|
102
|
+
const server = await startServer(dataHome, directory, () => ({}));
|
|
103
|
+
server.handle("compartment-count", async () => ({ count: 0 }));
|
|
104
|
+
initRpcClient(directory);
|
|
105
|
+
|
|
106
|
+
expect(await getCompartmentCount("ses_zero")).toEqual({ ok: true, count: 0 });
|
|
107
|
+
server.handle("compartment-count", async () => {
|
|
108
|
+
throw new Error("database unavailable");
|
|
109
|
+
});
|
|
110
|
+
const failed = await getCompartmentCount("ses_zero");
|
|
111
|
+
expect(failed.ok).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -102,7 +102,13 @@ const STICKY_MAX_ENTRIES = 100;
|
|
|
102
102
|
const stickySidebarCache = new Map<string, CachedSnapshot>();
|
|
103
103
|
|
|
104
104
|
function rememberSidebarSnapshot(snapshot: SidebarSnapshot): void {
|
|
105
|
-
if (!snapshot.sessionId
|
|
105
|
+
if (!snapshot.sessionId) return;
|
|
106
|
+
if (snapshot.inputTokens <= 0) {
|
|
107
|
+
// A successful zero is authoritative (new/deleted/reverted session) and
|
|
108
|
+
// must prevent a later transport failure from resurrecting old values.
|
|
109
|
+
stickySidebarCache.delete(snapshot.sessionId);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
106
112
|
// LRU-style bound: drop the oldest entry once we hit the cap. With a
|
|
107
113
|
// 5-min TTL most stale entries time out naturally; this just prevents
|
|
108
114
|
// unbounded growth across many session switches in a long TUI session.
|
|
@@ -126,19 +132,9 @@ function recallSidebarSnapshot(sessionId: string, fallback: SidebarSnapshot): Si
|
|
|
126
132
|
stickySidebarCache.delete(sessionId);
|
|
127
133
|
return fallback;
|
|
128
134
|
}
|
|
129
|
-
if (!hasInFlightEvidence(fallback)) {
|
|
130
|
-
stickySidebarCache.delete(sessionId);
|
|
131
|
-
return fallback;
|
|
132
|
-
}
|
|
133
135
|
return cached.snapshot;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
function hasInFlightEvidence(snapshot: SidebarSnapshot): boolean {
|
|
137
|
-
return (
|
|
138
|
-
snapshot.compartmentInProgress || snapshot.historianRunning || snapshot.pendingOpsCount > 0
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
138
|
/** Fetch sidebar snapshot from the server via RPC. */
|
|
143
139
|
export async function loadSidebarSnapshot(
|
|
144
140
|
sessionId: string,
|
|
@@ -152,9 +148,11 @@ export async function loadSidebarSnapshot(
|
|
|
152
148
|
directory,
|
|
153
149
|
});
|
|
154
150
|
if ((result as unknown as Record<string, unknown>).error) {
|
|
151
|
+
// Snapshot-build errors are explicit failure envelopes, equivalent to
|
|
152
|
+
// a transport failure: retain the last known-good client snapshot.
|
|
155
153
|
return recallSidebarSnapshot(sessionId, empty);
|
|
156
154
|
}
|
|
157
|
-
// Trust successful server responses. The server has its own sticky
|
|
155
|
+
// Trust successful server responses, including authoritative zeroes. The server has its own sticky
|
|
158
156
|
// sidebar cache (`sidebar-snapshot-cache.ts`) that handles transient
|
|
159
157
|
// zero-token windows by hybriding cached breakdown values into a
|
|
160
158
|
// fresh snapshot, AND clears that cache on `session.deleted`. If the
|
|
@@ -249,14 +247,23 @@ export async function loadEmbedDetail(sessionId: string, directory: string): Pro
|
|
|
249
247
|
}
|
|
250
248
|
}
|
|
251
249
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
export type CompartmentCountResult = { ok: true; count: number } | { ok: false; error: string };
|
|
251
|
+
|
|
252
|
+
/** Get compartment count without making transport failure look like a real zero. */
|
|
253
|
+
export async function getCompartmentCount(sessionId: string): Promise<CompartmentCountResult> {
|
|
254
|
+
if (!rpcClient) return { ok: false, error: "RPC client is not initialized" };
|
|
255
255
|
try {
|
|
256
|
-
const result = await rpcClient.call<{ count
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
256
|
+
const result = await rpcClient.call<{ count?: number; error?: string }>(
|
|
257
|
+
"compartment-count",
|
|
258
|
+
{ sessionId },
|
|
259
|
+
);
|
|
260
|
+
if (typeof result.error === "string") return { ok: false, error: result.error };
|
|
261
|
+
if (typeof result.count !== "number" || !Number.isFinite(result.count)) {
|
|
262
|
+
return { ok: false, error: "Invalid compartment count response" };
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, count: result.count };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
__resetNotificationStateForTests,
|
|
6
7
|
drainNotifications,
|
|
7
8
|
isTuiConnected,
|
|
8
9
|
pushNotification,
|
|
9
10
|
} from "../../shared/rpc-notifications";
|
|
10
11
|
import { MagicContextRpcServer } from "../../shared/rpc-server";
|
|
12
|
+
import { rpcPortFilePath } from "../../shared/rpc-utils";
|
|
11
13
|
import { closeRpc, initRpcClient } from "./context-db";
|
|
12
14
|
import {
|
|
13
15
|
_resetNotificationSocketStateForTesting,
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
|
19
21
|
const tempDirs: string[] = [];
|
|
20
22
|
const servers: MagicContextRpcServer[] = [];
|
|
23
|
+
const legacyServers: Array<ReturnType<typeof Bun.serve>> = [];
|
|
21
24
|
|
|
22
25
|
afterEach(() => {
|
|
23
26
|
_resetNotificationSocketStateForTesting();
|
|
@@ -25,7 +28,8 @@ afterEach(() => {
|
|
|
25
28
|
for (const server of servers.splice(0)) {
|
|
26
29
|
server.stop();
|
|
27
30
|
}
|
|
28
|
-
|
|
31
|
+
for (const server of legacyServers.splice(0)) server.stop(true);
|
|
32
|
+
__resetNotificationStateForTests();
|
|
29
33
|
for (const dir of tempDirs.splice(0)) {
|
|
30
34
|
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
31
35
|
}
|
|
@@ -54,6 +58,77 @@ async function startServer(dataHome: string, directory: string): Promise<MagicCo
|
|
|
54
58
|
return server;
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
function startLegacyServer(
|
|
62
|
+
dataHome: string,
|
|
63
|
+
directory: string,
|
|
64
|
+
notifications: SocketNotification[],
|
|
65
|
+
): {
|
|
66
|
+
sockets: Set<{ close(): void }>;
|
|
67
|
+
helloCursors: number[];
|
|
68
|
+
ackCursors: number[];
|
|
69
|
+
} {
|
|
70
|
+
const token = "v032-query-token";
|
|
71
|
+
const sockets = new Set<{ close(): void }>();
|
|
72
|
+
const helloCursors: number[] = [];
|
|
73
|
+
const ackCursors: number[] = [];
|
|
74
|
+
const server = Bun.serve({
|
|
75
|
+
hostname: "127.0.0.1",
|
|
76
|
+
port: 0,
|
|
77
|
+
fetch(req, bunServer) {
|
|
78
|
+
const url = new URL(req.url);
|
|
79
|
+
if (url.pathname === "/health") {
|
|
80
|
+
// Frozen v0.32 response: pid only, with no instance_id.
|
|
81
|
+
return Response.json({ ok: true, pid: process.pid });
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
url.pathname === "/ws" &&
|
|
85
|
+
req.headers.get("authorization") === `Bearer ${token}` &&
|
|
86
|
+
bunServer.upgrade(req, { data: {} })
|
|
87
|
+
) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return new Response("Not Found", { status: 404 });
|
|
91
|
+
},
|
|
92
|
+
websocket: {
|
|
93
|
+
open(ws) {
|
|
94
|
+
sockets.add(ws);
|
|
95
|
+
},
|
|
96
|
+
message(ws, raw) {
|
|
97
|
+
const msg = JSON.parse(String(raw)) as {
|
|
98
|
+
type?: string;
|
|
99
|
+
lastReceivedId?: number;
|
|
100
|
+
cursor?: number;
|
|
101
|
+
};
|
|
102
|
+
if (msg.type === "hello") {
|
|
103
|
+
const cursor = Number(msg.lastReceivedId ?? 0);
|
|
104
|
+
helloCursors.push(cursor);
|
|
105
|
+
for (const notification of notifications) {
|
|
106
|
+
if (notification.id > cursor) {
|
|
107
|
+
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Frozen v0.32 acknowledgement: protocol epoch fields did not exist.
|
|
111
|
+
ws.send(JSON.stringify({ type: "hello-ack" }));
|
|
112
|
+
} else if (msg.type === "ack" && typeof msg.cursor === "number") {
|
|
113
|
+
ackCursors.push(msg.cursor);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
close(ws) {
|
|
117
|
+
sockets.delete(ws);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
legacyServers.push(server);
|
|
122
|
+
|
|
123
|
+
const portFile = rpcPortFilePath(storageDir(dataHome), directory, process.pid);
|
|
124
|
+
mkdirSync(dirname(portFile), { recursive: true });
|
|
125
|
+
writeFileSync(
|
|
126
|
+
portFile,
|
|
127
|
+
JSON.stringify({ port: server.port, pid: process.pid, started_at: Date.now(), token }),
|
|
128
|
+
);
|
|
129
|
+
return { sockets, helloCursors, ackCursors };
|
|
130
|
+
}
|
|
131
|
+
|
|
57
132
|
async function waitFor(condition: () => boolean, label: string, timeoutMs = 4_000): Promise<void> {
|
|
58
133
|
const start = Date.now();
|
|
59
134
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -64,6 +139,58 @@ async function waitFor(condition: () => boolean, label: string, timeoutMs = 4_00
|
|
|
64
139
|
}
|
|
65
140
|
|
|
66
141
|
describe("notification socket", () => {
|
|
142
|
+
test("a home-screen socket receives globals but leaves scoped notifications pending", async () => {
|
|
143
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
144
|
+
const dataHome = makeDataHome();
|
|
145
|
+
const directory = "/repo-home-screen";
|
|
146
|
+
await startServer(dataHome, directory);
|
|
147
|
+
initRpcClient(directory);
|
|
148
|
+
|
|
149
|
+
const received: SocketNotification[] = [];
|
|
150
|
+
startNotificationSocket({
|
|
151
|
+
getSessionId: () => null,
|
|
152
|
+
onNotification: (notification) => {
|
|
153
|
+
received.push(notification);
|
|
154
|
+
return true;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
await waitFor(() => isTuiConnected(), "home-screen socket connection");
|
|
158
|
+
expect(isTuiConnected("ses_hidden")).toBe(false);
|
|
159
|
+
|
|
160
|
+
pushNotification("scoped", { action: "show-status-dialog" }, "ses_hidden");
|
|
161
|
+
pushNotification("global", { action: "show-status-dialog" });
|
|
162
|
+
await waitFor(
|
|
163
|
+
() => received.some((notification) => notification.type === "global"),
|
|
164
|
+
"global home-screen notification",
|
|
165
|
+
);
|
|
166
|
+
expect(received.some((notification) => notification.type === "scoped")).toBe(false);
|
|
167
|
+
expect(drainNotifications(0, "ses_hidden", { sessionOnly: true })).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("overlapping starts create one socket and one delivery", async () => {
|
|
171
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
172
|
+
const dataHome = makeDataHome();
|
|
173
|
+
const directory = "/repo-overlapping-start";
|
|
174
|
+
await startServer(dataHome, directory);
|
|
175
|
+
initRpcClient(directory);
|
|
176
|
+
|
|
177
|
+
let deliveries = 0;
|
|
178
|
+
const options = {
|
|
179
|
+
getSessionId: () => "ses_once",
|
|
180
|
+
onNotification: () => {
|
|
181
|
+
deliveries += 1;
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
startNotificationSocket(options);
|
|
186
|
+
startNotificationSocket(options);
|
|
187
|
+
await waitFor(() => isTuiConnected("ses_once"), "single overlapping connection");
|
|
188
|
+
pushNotification("once", { ok: true }, "ses_once");
|
|
189
|
+
await waitFor(() => deliveries === 1, "single notification delivery");
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
191
|
+
expect(deliveries).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
67
194
|
test("uses the active session cursor when switching sessions", async () => {
|
|
68
195
|
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
69
196
|
const dataHome = makeDataHome();
|
|
@@ -99,8 +226,8 @@ describe("notification socket", () => {
|
|
|
99
226
|
expect(received.map((notification) => notification.type)).toContain("for-b");
|
|
100
227
|
});
|
|
101
228
|
|
|
102
|
-
test("
|
|
103
|
-
|
|
229
|
+
test("clears cursors and deduplication when a fresh server reuses notification ids", async () => {
|
|
230
|
+
__resetNotificationStateForTests();
|
|
104
231
|
const dataHome = makeDataHome();
|
|
105
232
|
const directory = "/repo-reconnect";
|
|
106
233
|
const first = await startServer(dataHome, directory);
|
|
@@ -116,16 +243,155 @@ describe("notification socket", () => {
|
|
|
116
243
|
});
|
|
117
244
|
await waitFor(() => isTuiConnected("ses_R"), "initial websocket connection");
|
|
118
245
|
|
|
119
|
-
|
|
120
|
-
|
|
246
|
+
pushNotification("before-restart", { action: "show-status-dialog" }, "ses_R");
|
|
247
|
+
await waitFor(() => received.length === 1, "first notification acknowledgement");
|
|
248
|
+
await waitFor(
|
|
249
|
+
() => drainNotifications(0, "ses_R").length === 0,
|
|
250
|
+
"first server queue to empty",
|
|
251
|
+
);
|
|
252
|
+
expect(received[0].id).toBe(1);
|
|
253
|
+
|
|
121
254
|
first.stop();
|
|
122
255
|
await waitFor(() => !isTuiConnected("ses_R"), "old websocket sink removed");
|
|
123
|
-
|
|
256
|
+
// A process restart recreates module state as well as the RPC server. The
|
|
257
|
+
// client state intentionally survives so reused ids exercise epoch reset.
|
|
258
|
+
__resetNotificationStateForTests();
|
|
259
|
+
pushNotification("queued-after-restart", { action: "show-status-dialog" }, "ses_R");
|
|
260
|
+
const second = await startServer(dataHome, directory);
|
|
261
|
+
expect(second).toBeDefined();
|
|
124
262
|
|
|
125
|
-
pushNotification("after-restart", { action: "show-status-dialog" }, "ses_R");
|
|
126
263
|
await waitFor(
|
|
127
|
-
() => received.some((notification) => notification.type === "after-restart"),
|
|
128
|
-
"
|
|
264
|
+
() => received.some((notification) => notification.type === "queued-after-restart"),
|
|
265
|
+
"reused id from replacement server backlog",
|
|
129
266
|
);
|
|
267
|
+
const restarted = received.find(
|
|
268
|
+
(notification) => notification.type === "queued-after-restart",
|
|
269
|
+
);
|
|
270
|
+
expect(restarted?.id).toBe(received[0].id);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("acknowledges only consumed ids and redelivers a failed earlier notification", async () => {
|
|
274
|
+
__resetNotificationStateForTests();
|
|
275
|
+
const dataHome = makeDataHome();
|
|
276
|
+
const directory = "/repo-exact-ack";
|
|
277
|
+
await startServer(dataHome, directory);
|
|
278
|
+
initRpcClient(directory);
|
|
279
|
+
|
|
280
|
+
let releaseFirst: ((consumed: boolean) => void) | undefined;
|
|
281
|
+
const firstResult = new Promise<boolean>((resolve) => {
|
|
282
|
+
releaseFirst = resolve;
|
|
283
|
+
});
|
|
284
|
+
let slowCalls = 0;
|
|
285
|
+
let fastCalls = 0;
|
|
286
|
+
startNotificationSocket({
|
|
287
|
+
getSessionId: () => "ses_exact",
|
|
288
|
+
onNotification: (notification) => {
|
|
289
|
+
if (notification.type === "slow") {
|
|
290
|
+
slowCalls += 1;
|
|
291
|
+
return slowCalls === 1 ? firstResult : true;
|
|
292
|
+
}
|
|
293
|
+
fastCalls += 1;
|
|
294
|
+
return true;
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
await waitFor(() => isTuiConnected("ses_exact"), "exact ack socket connection");
|
|
298
|
+
|
|
299
|
+
pushNotification("slow", { action: "show-status-dialog" }, "ses_exact");
|
|
300
|
+
pushNotification("fast", { action: "show-toast" }, "ses_exact");
|
|
301
|
+
await waitFor(() => slowCalls === 1, "blocked first handler");
|
|
302
|
+
expect(fastCalls).toBe(0);
|
|
303
|
+
releaseFirst?.(false);
|
|
304
|
+
await waitFor(() => fastCalls === 1, "later handler completion");
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
const pending = drainNotifications(0, "ses_exact");
|
|
307
|
+
return pending.length === 1 && pending[0].type === "slow";
|
|
308
|
+
}, "only failed notification to remain queued");
|
|
309
|
+
|
|
310
|
+
_resetNotificationSocketStateForTesting();
|
|
311
|
+
await waitFor(() => !isTuiConnected("ses_exact"), "socket disconnect before redelivery");
|
|
312
|
+
startNotificationSocket({
|
|
313
|
+
getSessionId: () => "ses_exact",
|
|
314
|
+
onNotification: (notification) => {
|
|
315
|
+
if (notification.type === "slow") slowCalls += 1;
|
|
316
|
+
if (notification.type === "fast") fastCalls += 1;
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
await waitFor(() => slowCalls === 2, "failed notification redelivery");
|
|
321
|
+
expect(fastCalls).toBe(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("legacy mode keeps its watermark behind a declined notification across reconnect", async () => {
|
|
325
|
+
const dataHome = makeDataHome();
|
|
326
|
+
const directory = "/repo-v032-notifications";
|
|
327
|
+
const sessionId = "ses_v032";
|
|
328
|
+
const legacy = startLegacyServer(dataHome, directory, [
|
|
329
|
+
{ id: 1, type: "declined", payload: {}, sessionId },
|
|
330
|
+
{ id: 2, type: "consumed", payload: {}, sessionId },
|
|
331
|
+
]);
|
|
332
|
+
initRpcClient(directory);
|
|
333
|
+
|
|
334
|
+
let declinedCalls = 0;
|
|
335
|
+
let consumedCalls = 0;
|
|
336
|
+
startNotificationSocket({
|
|
337
|
+
getSessionId: () => sessionId,
|
|
338
|
+
onNotification: (notification) => {
|
|
339
|
+
if (notification.type === "declined") {
|
|
340
|
+
declinedCalls += 1;
|
|
341
|
+
return declinedCalls > 1;
|
|
342
|
+
}
|
|
343
|
+
consumedCalls += 1;
|
|
344
|
+
return true;
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await waitFor(() => declinedCalls === 1 && consumedCalls === 1, "legacy backlog handling");
|
|
349
|
+
expect(Math.max(0, ...legacy.ackCursors)).toBe(0);
|
|
350
|
+
for (const ws of legacy.sockets) ws.close();
|
|
351
|
+
|
|
352
|
+
await waitFor(() => legacy.helloCursors.length >= 2, "legacy reconnect hello");
|
|
353
|
+
await waitFor(() => declinedCalls === 2, "declined notification redelivery");
|
|
354
|
+
expect(legacy.helloCursors[1]).toBe(0);
|
|
355
|
+
expect(consumedCalls).toBe(1);
|
|
356
|
+
await waitFor(() => legacy.ackCursors.includes(2), "gap-safe legacy watermark advance");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("serializes back-to-back dialog notification handlers", async () => {
|
|
360
|
+
__resetNotificationStateForTests();
|
|
361
|
+
const dataHome = makeDataHome();
|
|
362
|
+
const directory = "/repo-serialized-dialogs";
|
|
363
|
+
await startServer(dataHome, directory);
|
|
364
|
+
initRpcClient(directory);
|
|
365
|
+
|
|
366
|
+
let releaseFirst: (() => void) | undefined;
|
|
367
|
+
const firstSettled = new Promise<void>((resolve) => {
|
|
368
|
+
releaseFirst = resolve;
|
|
369
|
+
});
|
|
370
|
+
const events: string[] = [];
|
|
371
|
+
startNotificationSocket({
|
|
372
|
+
getSessionId: () => "ses_dialogs",
|
|
373
|
+
onNotification: async (notification) => {
|
|
374
|
+
events.push(`start:${notification.type}`);
|
|
375
|
+
if (notification.type === "dialog-one") await firstSettled;
|
|
376
|
+
events.push(`finish:${notification.type}`);
|
|
377
|
+
return true;
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
await waitFor(() => isTuiConnected("ses_dialogs"), "dialog socket connection");
|
|
381
|
+
|
|
382
|
+
pushNotification("dialog-one", { action: "show-status-dialog" }, "ses_dialogs");
|
|
383
|
+
pushNotification("dialog-two", { action: "show-upgrade-dialog" }, "ses_dialogs");
|
|
384
|
+
await waitFor(() => events.includes("start:dialog-one"), "first dialog handler start");
|
|
385
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
386
|
+
expect(events).toEqual(["start:dialog-one"]);
|
|
387
|
+
|
|
388
|
+
releaseFirst?.();
|
|
389
|
+
await waitFor(() => events.includes("finish:dialog-two"), "second dialog handler finish");
|
|
390
|
+
expect(events).toEqual([
|
|
391
|
+
"start:dialog-one",
|
|
392
|
+
"finish:dialog-one",
|
|
393
|
+
"start:dialog-two",
|
|
394
|
+
"finish:dialog-two",
|
|
395
|
+
]);
|
|
130
396
|
});
|
|
131
397
|
});
|